# Get a swap price estimate Source: https://docs.portalhq.io/api-reference/0x/get-a-swap-price-estimate /openapi/client-api.yaml post /clients/me/integrations/0x/swap/price Retrieves a swap price estimate for trading tokens on a specific chain without building a complete transaction. Use this endpoint for displaying price quotes to users before they commit to a swap. # Get a swap quote Source: https://docs.portalhq.io/api-reference/0x/get-a-swap-quote /openapi/client-api.yaml post /clients/me/integrations/0x/swap/quote Retrieves a swap quote with full transaction details for trading tokens on a specific chain. Use this endpoint when you are ready to execute a swap and need the complete transaction to sign and broadcast. # Get available swap sources Source: https://docs.portalhq.io/api-reference/0x/get-available-swap-sources /openapi/client-api.yaml post /clients/me/integrations/0x/swap/sources Retrieves the available swap liquidity sources for a specific chain. Optionally provide your own 0x API key via `zeroXApiKey`, otherwise Portal uses a managed key if available. # Get cross-chain swap quotes Source: https://docs.portalhq.io/api-reference/0x/get-cross-chain-swap-quotes /openapi/client-api.yaml post /clients/me/integrations/0x/cross-chain/quotes Retrieves quotes for a cross-chain swap across EVM chains. Returns multiple quotes with different bridge providers and routing options. **Experimental:** This endpoint requires explicit enablement by the Portal and 0x teams before it can be used. The `sellToken` and `buyToken` parameters accept contract addresses, native token keywords (`NATIVE`, `ETH`, `AVAX`, `SOL`, etc.), or supported token symbols (`USDC`, `USDT`). Each quote includes a Portal-formatted `transaction` object ready to sign and broadcast. If token approval is needed, an `approvalTransaction` is also included — sign and submit it before the main transaction. For more details on the underlying 0x cross-chain API, see the [0x Cross-Chain API docs](https://0x-docs.gitbook.io/0x-cross-chain-api-beta/api-reference/cross-chain). # Get cross-chain transaction history Source: https://docs.portalhq.io/api-reference/0x/get-cross-chain-transaction-history /openapi/client-api.yaml get /clients/me/integrations/0x/cross-chain/tx-history Retrieves cross-chain transaction history for a wallet address. Supports pagination via cursor. # Get cross-chain transaction status Source: https://docs.portalhq.io/api-reference/0x/get-cross-chain-transaction-status /openapi/client-api.yaml get /clients/me/integrations/0x/cross-chain/status Tracks the status of a cross-chain transaction from origin chain submission through bridge completion. # List cross-chain bridge and swap sources Source: https://docs.portalhq.io/api-reference/0x/list-cross-chain-bridge-and-swap-sources /openapi/client-api.yaml get /clients/me/integrations/0x/cross-chain/sources Lists all available bridge providers and DEX sources for cross-chain swaps, including their supported chain pairs. Chain IDs are returned in CAIP-2 format. # Create an external address Source: https://docs.portalhq.io/api-reference/alert-webhooks/create-an-external-address /openapi/custodian-api.yaml post /custodians/me/alerts/webhooks/external-addresses Add an external blockchain address to receive alert webhook notifications for. The address is validated for correct format based on the selected namespace. Blackhole addresses (such as null or dead addresses) are rejected. Supported namespaces: - `eip155` — EVM-compatible addresses - `solana` — Solana addresses # Delete an external address Source: https://docs.portalhq.io/api-reference/alert-webhooks/delete-an-external-address /openapi/custodian-api.yaml delete /custodians/me/alerts/webhooks/external-addresses/{externalAddressId} Remove an external address from alert webhook notifications. The address will no longer be monitored for transactions. # Format a raw alert webhook event (deprecated) Source: https://docs.portalhq.io/api-reference/alert-webhooks/format-a-raw-alert-webhook-event-deprecated /openapi/custodian-api.yaml post /custodians/me/alerts/wallets/format **Deprecated.** Use the alert webhooks endpoints instead. Formats a raw webhook request body for a single wallet event. # Format raw alert webhook events (deprecated) Source: https://docs.portalhq.io/api-reference/alert-webhooks/format-raw-alert-webhook-events-deprecated /openapi/custodian-api.yaml post /custodians/me/alerts/format **Deprecated.** Use the alert webhooks endpoints instead. Formats a raw webhook request body for wallet events, which may include one or many transactions at once. # List alert webhook delivery events Source: https://docs.portalhq.io/api-reference/alert-webhooks/list-alert-webhook-delivery-events /openapi/custodian-api.yaml get /custodians/me/alerts/webhooks/{alertWebhookId}/delivery-events Retrieve a cursor-paginated list of delivery events for an alert webhook, including those that are pending, currently being replayed, delivered, or failed. Use this endpoint to inspect delivery history and identify events that need to be retried with `POST /custodians/me/alerts/webhooks/{alertWebhookId}/delivery-events/{deliveryEventId}/retries`. See [Delivery event lifecycle](/resources/alert-webhooks#delivery-event-lifecycle) for the meaning of each `status` value and how manual retries interact with the automatic retry loop. # List alert webhook events Source: https://docs.portalhq.io/api-reference/alert-webhooks/list-alert-webhook-events /openapi/custodian-api.yaml get /custodians/me/alerts/webhooks/{alertWebhookId}/events Retrieve a list of events sent to your alert webhook, including those that failed to be delivered. # List alert webhooks Source: https://docs.portalhq.io/api-reference/alert-webhooks/list-alert-webhooks /openapi/custodian-api.yaml get /custodians/me/alerts/webhooks Retrieve a list of your alert webhooks configured in your Portal Admin Dashboard. # List external addresses Source: https://docs.portalhq.io/api-reference/alert-webhooks/list-external-addresses /openapi/custodian-api.yaml get /custodians/me/alerts/webhooks/external-addresses Retrieve a list of external addresses configured for alert webhook notifications in your environment. External addresses are blockchain addresses not generated by Portal that you still want to monitor for transactions. # Replay alert by block number (deprecated) Source: https://docs.portalhq.io/api-reference/alert-webhooks/replay-alert-by-block-number-deprecated /openapi/custodian-api.yaml post /custodians/me/alerts/retry-by-block-number **Deprecated.** Use the `replay failed events` endpoint instead. Attempts to resend a wallet event scoped by a block number. Supports EVM chains only. # Replay failed alert webhook events Source: https://docs.portalhq.io/api-reference/alert-webhooks/replay-failed-alert-webhook-events /openapi/custodian-api.yaml post /custodians/me/alerts/webhooks/{alertWebhookId}/events/failed/replay **Deprecated.** Use `POST /custodians/me/alerts/webhooks/{alertWebhookId}/delivery-events/{deliveryEventId}/retries` instead to retry one delivery event at a time. The new endpoint also supports retrying events that are still `PENDING`, not just those in `FAILED` state. Replays a list of alert webhook events with a delivery status of `"failed"`. If a failed event has already been replayed successfully, it cannot be replayed again. # Retry an alert webhook delivery event Source: https://docs.portalhq.io/api-reference/alert-webhooks/retry-an-alert-webhook-delivery-event /openapi/custodian-api.yaml post /custodians/me/alerts/webhooks/{alertWebhookId}/delivery-events/{deliveryEventId}/retries Manually retry the delivery of a single alert webhook event. The event's status is set to `REPLAYING` and a one-shot delivery attempt is scheduled on a dedicated retry queue. On completion, the event moves to `DELIVERED` (2XX response) or `FAILED` (non-2XX response or timeout). **Manual retries replace the automatic retry loop.** If the target event is still `PENDING`, retrying it removes it from the normal automatic retry flow — the manual retry becomes the only remaining attempt. See [Delivery event lifecycle](/resources/alert-webhooks#delivery-event-lifecycle) for the full state machine. # Get the enabled auth methods Source: https://docs.portalhq.io/api-reference/auth-methods/get-the-enabled-auth-methods /openapi/authentication-api.yaml get /methods Returns the sign-in methods enabled for the environment, so your app can render only the buttons that will work. Also returns whether the environment is configured to create a wallet for new users. Call this before rendering your sign-in screen. # Scan a TRON transaction Source: https://docs.portalhq.io/api-reference/blockaid/scan-a-tron-transaction /openapi/client-api.yaml post /clients/me/integrations/blockaid/tron/scan Scans a TRON transaction for security risks using Blockaid. Only TRON mainnet is supported; testnet chain IDs are rejected. # Scan a URL Source: https://docs.portalhq.io/api-reference/blockaid/scan-a-url /openapi/client-api.yaml post /clients/me/integrations/blockaid/url/scan Scans a URL for phishing or malicious content using Blockaid. # Scan an address Source: https://docs.portalhq.io/api-reference/blockaid/scan-an-address /openapi/client-api.yaml post /clients/me/integrations/blockaid/address/scan Scans a blockchain address for malicious activity using Blockaid. # Scan an EVM transaction Source: https://docs.portalhq.io/api-reference/blockaid/scan-an-evm-transaction /openapi/client-api.yaml post /clients/me/integrations/blockaid/evm/scan Scans an EVM transaction for security risks using Blockaid. Returns risk assessment and simulation results. # Scan Solana transactions Source: https://docs.portalhq.io/api-reference/blockaid/scan-solana-transactions /openapi/client-api.yaml post /clients/me/integrations/blockaid/solana/scan Scans Solana transactions for security risks using Blockaid. # Scan tokens Source: https://docs.portalhq.io/api-reference/blockaid/scan-tokens /openapi/client-api.yaml post /clients/me/integrations/blockaid/tokens/scan Scans one or more tokens for malicious characteristics using Blockaid. # Create a client Source: https://docs.portalhq.io/api-reference/clients/create-a-client /openapi/custodian-api.yaml post /custodians/me/clients Registers a new client and returns a client API key, client session token, and whether account abstraction is enabled. # Create a client session token Source: https://docs.portalhq.io/api-reference/clients/create-a-client-session-token /openapi/custodian-api.yaml post /custodians/me/clients/{clientId}/sessions Creates a new Client Session Token (CST) for a client. The CST can be used by the client SDK to authenticate API requests. # Create a web OTP Source: https://docs.portalhq.io/api-reference/clients/create-a-web-otp /openapi/custodian-api.yaml post /custodians/me/clients/{clientId}/web-otps Generates a one-time password (OTP) for a client using the Web SDK. **Note:** The legacy v1 endpoint (`GET /api/v1/custodians/clients/{clientId}/web-otp`) is still available but this v3 POST endpoint is preferred. # Disable or enable a client capability Source: https://docs.portalhq.io/api-reference/clients/disable-or-enable-a-client-capability /openapi/custodian-api.yaml patch /custodians/me/clients/{clientId}/capabilities Disables or re-enables a single capability for the specified client. Disabling the `SIGN` capability blocks every signing request for that client, even when the request carries a valid client API key. Use this to freeze a client you believe is compromised. Re-enable it by sending the same request with `disabled: false`. The current state is returned by the **Get a client** endpoint in `disabledCapabilities`. # Get a client Source: https://docs.portalhq.io/api-reference/clients/get-a-client /openapi/custodian-api.yaml get /custodians/me/clients/{clientId} Fetches the specified client for the authorized custodian, including wallet details, backup share pairs, and signing share pairs. # Get the client's details Source: https://docs.portalhq.io/api-reference/clients/get-the-clients-details /openapi/client-api.yaml get /clients/me Retrieves the details of the current client, including information about associated wallets, backup share pairs, and signing share pairs. # List clients Source: https://docs.portalhq.io/api-reference/clients/list-clients /openapi/custodian-api.yaml get /custodians/me/clients Fetches all clients for the authorized custodian with cursor-based pagination. # Mark client as ejected Source: https://docs.portalhq.io/api-reference/clients/mark-client-as-ejected /openapi/client-api.yaml post /clients/me/eject Marks the client and its wallets as ejected by setting the `ejectedAt` timestamp. This is a status update that should be called after the eject operation completes on the SDK/MPC side. This is irreversible. # Approve a Solana delegation Source: https://docs.portalhq.io/api-reference/delegations/approve-a-solana-delegation /openapi/client-api.yaml post /clients/me/chains/{chain}/delegation/approve Builds a transaction to approve a Solana token delegation. # Approve a token delegation Source: https://docs.portalhq.io/api-reference/delegations/approve-a-token-delegation /openapi/client-api.yaml post /clients/me/chains/{chain}/assets/{token}/approvals Builds a transaction to approve a delegate address to spend a specified amount of the given token on behalf of the client. The response format differs between EVM and Solana chains. For **EVM chains**, returns unsigned transaction objects. For **Solana**, returns base58/base64-encoded transactions. # Get delegation status for a token Source: https://docs.portalhq.io/api-reference/delegations/get-delegation-status-for-a-token /openapi/custodian-api.yaml get /custodians/me/clients/{clientId}/chains/{chain}/assets/{token}/delegations Retrieves the current delegation status for a specific client and token, including the balance and active delegations. See the delegateAddress parameter for per-chain behavior. # Get Solana delegation status Source: https://docs.portalhq.io/api-reference/delegations/get-solana-delegation-status /openapi/custodian-api.yaml get /custodians/me/clients/{clientId}/chains/{chain}/delegation/status Retrieves the delegation status for a Solana token account, including information about the token balance, delegate address, and delegated amount. **Supported chains:** Solana Mainnet and Solana Devnet only. # Initialize a delegation system account Source: https://docs.portalhq.io/api-reference/delegations/initialize-a-delegation-system-account /openapi/client-api.yaml post /clients/me/chains/{chain}/delegation/init-system-account Initializes a Solana system account for delegation operations. # Initialize a delegation token account Source: https://docs.portalhq.io/api-reference/delegations/initialize-a-delegation-token-account /openapi/client-api.yaml post /clients/me/chains/{chain}/delegation/init-token-account Initializes a Solana token account for delegation operations. # Revoke a Solana delegation Source: https://docs.portalhq.io/api-reference/delegations/revoke-a-solana-delegation /openapi/client-api.yaml post /clients/me/chains/{chain}/delegation/revoke Builds a transaction to revoke all Solana token delegations. # Revoke a token delegation Source: https://docs.portalhq.io/api-reference/delegations/revoke-a-token-delegation /openapi/client-api.yaml post /clients/me/chains/{chain}/assets/{token}/revocations Builds a transaction to revoke a delegate address's approval to spend the given token on behalf of the client. The response format differs between EVM and Solana chains. # Transfer as a delegate Source: https://docs.portalhq.io/api-reference/delegations/transfer-as-a-delegate /openapi/custodian-api.yaml post /custodians/me/clients/{clientId}/chains/{chain}/assets/{token}/delegations/transfers Transfers tokens from a client on behalf of another address using delegated spending authority. Returns unsigned transaction(s) ready for signing. # Transfer as a Solana delegate Source: https://docs.portalhq.io/api-reference/delegations/transfer-as-a-solana-delegate /openapi/client-api.yaml post /clients/me/chains/{chain}/delegation/transfer Builds a transaction to transfer tokens as a delegate on Solana. # Transfer tokens as a delegate Source: https://docs.portalhq.io/api-reference/delegations/transfer-tokens-as-a-delegate /openapi/client-api.yaml post /clients/me/chains/{chain}/assets/{token}/delegations/transfers Builds a transaction to transfer tokens from one address to another using the delegate's approval. The response format differs between EVM and Solana chains. # Get balances (deprecated) Source: https://docs.portalhq.io/api-reference/deprecated/get-balances-deprecated /openapi/client-api.yaml get /clients/me/balances **Deprecated.** Use `/clients/me/chains/{chain}/assets` instead. Sunset date: 2026-12-31. Responses include `Deprecation`, `Sunset`, and `Link` headers. Retrieves token balances for the client on a specific chain. # Get NFTs (deprecated) Source: https://docs.portalhq.io/api-reference/deprecated/get-nfts-deprecated /openapi/client-api.yaml get /clients/me/nfts **Deprecated.** Use `/clients/me/chains/{chain}/assets/nfts` instead. Sunset date: 2026-12-31. Responses include `Deprecation`, `Sunset`, and `Link` headers. Retrieves NFTs owned by the client on a specific chain. # Get transactions (deprecated) Source: https://docs.portalhq.io/api-reference/deprecated/get-transactions-deprecated /openapi/client-api.yaml get /clients/me/transactions **Deprecated.** Use `/clients/me/chains/{chain}/transactions` instead. Sunset date: 2026-12-31. Responses include `Deprecation`, `Sunset`, and `Link` headers. Retrieves EIP-155 transaction history for the client. # Simulate a transaction Source: https://docs.portalhq.io/api-reference/deprecated/simulate-a-transaction /openapi/client-api.yaml post /clients/me/simulate-transaction Simulates an EVM transaction and returns the expected state changes and gas usage. Only supports EIP-155 chains. **Deprecated:** Use the `evaluate-transaction` endpoint instead. # Accept terms of service Source: https://docs.portalhq.io/api-reference/due/accept-terms-of-service /openapi/client-api.yaml post /clients/me/integrations/due/tos/{token} Records the end user's acceptance of a terms of service document. # Complete KYC submission Source: https://docs.portalhq.io/api-reference/due/complete-kyc-submission /openapi/client-api.yaml post /clients/me/integrations/due/kyc/submissions/{id}/complete Submits a KYC submission for review. # Create funding address Source: https://docs.portalhq.io/api-reference/due/create-funding-address /openapi/client-api.yaml post /clients/me/integrations/due/transfers/{id}/funding-address Returns the funding instructions for a transfer. For a payin these are fiat bank details; for a payout, the onchain deposit address. # Create FX quote Source: https://docs.portalhq.io/api-reference/due/create-fx-quote /openapi/client-api.yaml post /clients/me/integrations/due/fx/quote Returns an FX quote for a currency pair. # Create KYC document Source: https://docs.portalhq.io/api-reference/due/create-kyc-document /openapi/client-api.yaml post /clients/me/integrations/due/kyc/submissions/{id}/documents Registers a document against a submission and returns an upload token. # Create recipient Source: https://docs.portalhq.io/api-reference/due/create-recipient /openapi/client-api.yaml post /clients/me/integrations/due/recipients Creates a recipient. For onchain recipients, pass a CAIP-2 `chainId` and omit the address to default it to the client's Portal wallet. For a fiat beneficiary, set `isExternal: true` and provide the bank `details`. # Create transfer Source: https://docs.portalhq.io/api-reference/due/create-transfer /openapi/client-api.yaml post /clients/me/integrations/due/transfers Creates a transfer from a quote token and a recipient. # Create transfer intent Source: https://docs.portalhq.io/api-reference/due/create-transfer-intent /openapi/client-api.yaml post /clients/me/integrations/due/transfers/{id}/transfer-intent Creates a transfer intent for a crypto-source transfer. # Create transfer quote Source: https://docs.portalhq.io/api-reference/due/create-transfer-quote /openapi/client-api.yaml post /clients/me/integrations/due/transfers/quote Prices a transfer. `source` and `destination` each carry a rail, currency, and amount. Application fees are enforced server-side from the custodian's configuration; any client-supplied `applicationFee*` is stripped. # Create virtual account Source: https://docs.portalhq.io/api-reference/due/create-virtual-account /openapi/client-api.yaml post /clients/me/integrations/due/virtual-accounts Creates a persistent virtual account that auto-converts fiat deposits to crypto in the destination wallet. # Create webhook endpoint Source: https://docs.portalhq.io/api-reference/due/create-webhook-endpoint /openapi/custodian-api.yaml post /custodians/me/integrations/due/webhooks Registers a webhook endpoint. The response includes an Ed25519 `publicKey` used to verify deliveries from this endpoint. # Delete recipient Source: https://docs.portalhq.io/api-reference/due/delete-recipient /openapi/client-api.yaml delete /clients/me/integrations/due/recipients/{id} Deletes a recipient by id. # Delete webhook endpoint Source: https://docs.portalhq.io/api-reference/due/delete-webhook-endpoint /openapi/custodian-api.yaml delete /custodians/me/integrations/due/webhooks/{webhookId} Removes a webhook endpoint. # Estimate transfer Source: https://docs.portalhq.io/api-reference/due/estimate-transfer /openapi/client-api.yaml post /clients/me/integrations/due/transfers/estimate Returns an indicative estimate without creating a quote token. # Get Due customer Source: https://docs.portalhq.io/api-reference/due/get-due-customer /openapi/client-api.yaml get /clients/me/integrations/due/customers Returns the Due customer for the authenticated client. # Get endorsement Source: https://docs.portalhq.io/api-reference/due/get-endorsement /openapi/client-api.yaml get /clients/me/integrations/due/kyc/endorsements/{code} Returns an endorsement's state and any extra requirements it adds. # Get financial institution Source: https://docs.portalhq.io/api-reference/due/get-financial-institution /openapi/client-api.yaml get /clients/me/integrations/due/financial-institutions/{financialInstitutionId} Returns a single financial institution by id. # Get FX market Source: https://docs.portalhq.io/api-reference/due/get-fx-market /openapi/client-api.yaml get /clients/me/integrations/due/fx/markets/{base}/{quote} Returns the FX market for a base and quote currency. # Get FX market history Source: https://docs.portalhq.io/api-reference/due/get-fx-market-history /openapi/client-api.yaml get /clients/me/integrations/due/fx/markets/{base}/{quote}/history Returns historical rates for a base and quote currency. # Get KYC status Source: https://docs.portalhq.io/api-reference/due/get-kyc-status /openapi/client-api.yaml get /clients/me/integrations/due/kyc Returns the customer's KYC state and outstanding requirements. # Get KYC submission Source: https://docs.portalhq.io/api-reference/due/get-kyc-submission /openapi/client-api.yaml get /clients/me/integrations/due/kyc/submissions/{id} Returns a KYC submission and its required fields. # Get recipient Source: https://docs.portalhq.io/api-reference/due/get-recipient /openapi/client-api.yaml get /clients/me/integrations/due/recipients/{id} Returns a recipient by id. # Get terms of service Source: https://docs.portalhq.io/api-reference/due/get-terms-of-service /openapi/client-api.yaml get /clients/me/integrations/due/tos/{token} Fetches a terms of service document by token. # Get transfer Source: https://docs.portalhq.io/api-reference/due/get-transfer /openapi/client-api.yaml get /clients/me/integrations/due/transfers/{id} Returns a transfer's current state. # Get usage Source: https://docs.portalhq.io/api-reference/due/get-usage /openapi/client-api.yaml get /clients/me/integrations/due/usage Returns the customer's Due usage records. # Get virtual account Source: https://docs.portalhq.io/api-reference/due/get-virtual-account /openapi/client-api.yaml get /clients/me/integrations/due/virtual-accounts/key/{key} Returns a virtual account by its composite key, `destination/schemaIn/currencyIn/railOut/currencyOut/reference`. # Get virtual account by query Source: https://docs.portalhq.io/api-reference/due/get-virtual-account-by-query /openapi/client-api.yaml get /clients/me/integrations/due/virtual-accounts Returns a virtual account matching the supplied query parameters. # Get wallet balance Source: https://docs.portalhq.io/api-reference/due/get-wallet-balance /openapi/client-api.yaml get /clients/me/integrations/due/wallets/{walletId}/balance Returns balances for a registered wallet. # List channels Source: https://docs.portalhq.io/api-reference/due/list-channels /openapi/client-api.yaml get /clients/me/integrations/due/channels Lists the rails and currencies available to the customer. Pass `onlyAvailable=true` to filter to channels the customer can use today. # List endorsements Source: https://docs.portalhq.io/api-reference/due/list-endorsements /openapi/client-api.yaml get /clients/me/integrations/due/kyc/endorsements Lists the customer's endorsements. # List financial institutions Source: https://docs.portalhq.io/api-reference/due/list-financial-institutions /openapi/client-api.yaml get /clients/me/integrations/due/financial-institutions/{country2}/{schema} Lists financial institutions for a country and bank schema. # List FX markets Source: https://docs.portalhq.io/api-reference/due/list-fx-markets /openapi/client-api.yaml get /clients/me/integrations/due/fx/markets Lists available FX markets and rates. # List recipients Source: https://docs.portalhq.io/api-reference/due/list-recipients /openapi/client-api.yaml get /clients/me/integrations/due/recipients Lists the customer's recipients. # List transfers Source: https://docs.portalhq.io/api-reference/due/list-transfers /openapi/client-api.yaml get /clients/me/integrations/due/transfers Lists the customer's transfers. # List virtual accounts Source: https://docs.portalhq.io/api-reference/due/list-virtual-accounts /openapi/client-api.yaml get /clients/me/integrations/due/virtual-accounts/list Lists the customer's virtual accounts. # List wallets Source: https://docs.portalhq.io/api-reference/due/list-wallets /openapi/client-api.yaml get /clients/me/integrations/due/wallets Lists the client's wallets registered with Due. # List webhook deliveries Source: https://docs.portalhq.io/api-reference/due/list-webhook-deliveries /openapi/custodian-api.yaml get /custodians/me/integrations/due/webhooks/{webhookId}/events Returns the delivery history for a webhook endpoint. The response is paginated: when more events exist, `data.next` holds the cursor for the next page. Query parameters are forwarded to Due, so pass that value back as Due's pagination cursor to page through the history. # List webhook endpoints Source: https://docs.portalhq.io/api-reference/due/list-webhook-endpoints /openapi/custodian-api.yaml get /custodians/me/integrations/due/webhooks Lists the environment's registered Due webhook endpoints. # List webhook event types Source: https://docs.portalhq.io/api-reference/due/list-webhook-event-types /openapi/custodian-api.yaml get /custodians/me/integrations/due/webhooks/events Lists the Due webhook event types you can subscribe to. # Provision Due customer Source: https://docs.portalhq.io/api-reference/due/provision-due-customer /openapi/client-api.yaml post /clients/me/integrations/due/customers Provisions the Due customer for the authenticated client. Idempotent: calling it again for the same client returns the existing customer. # Register wallet Source: https://docs.portalhq.io/api-reference/due/register-wallet /openapi/client-api.yaml post /clients/me/integrations/due/wallets Registers the client's Portal wallet address with Due. # Request endorsement Source: https://docs.portalhq.io/api-reference/due/request-endorsement /openapi/client-api.yaml post /clients/me/integrations/due/kyc/endorsements/{code} Requests an endorsement by code. Returns `409 Conflict` when the endorsement already exists; treat it as already in progress and fetch its state instead. # Retry webhook delivery Source: https://docs.portalhq.io/api-reference/due/retry-webhook-delivery /openapi/custodian-api.yaml post /custodians/me/integrations/due/webhooks/{webhookId}/events/{eventId}/retry Re-delivers a specific webhook event. # Start hosted KYC session Source: https://docs.portalhq.io/api-reference/due/start-hosted-kyc-session /openapi/client-api.yaml post /clients/me/integrations/due/kyc/session Creates a hosted KYC session and returns a URL to redirect the user to. # Start KYC submission Source: https://docs.portalhq.io/api-reference/due/start-kyc-submission /openapi/client-api.yaml post /clients/me/integrations/due/kyc Starts an API-driven KYC submission for the customer. # Submit KYC information Source: https://docs.portalhq.io/api-reference/due/submit-kyc-information /openapi/client-api.yaml post /clients/me/integrations/due/kyc/submissions/{id}/info Submits collected information for a KYC submission. Due validates the payload and returns field-level errors for anything missing or malformed. # Update virtual account Source: https://docs.portalhq.io/api-reference/due/update-virtual-account /openapi/client-api.yaml post /clients/me/integrations/due/virtual-accounts/key/{key} Updates a virtual account by its composite key. Application fees are enforced server-side; any client-supplied `applicationFee*` is stripped. # Update webhook endpoint Source: https://docs.portalhq.io/api-reference/due/update-webhook-endpoint /openapi/custodian-api.yaml post /custodians/me/integrations/due/webhooks/{webhookId} Updates a webhook endpoint. Send any of url, description, events, or enabled. # Upload KYC document Source: https://docs.portalhq.io/api-reference/due/upload-kyc-document /openapi/client-api.yaml post /clients/me/integrations/due/kyc/submissions/documents/{token} Uploads a document's bytes using the token from create document. The decoded file can be up to 10MB; the MIME type is inferred from the filename extension or supplied explicitly. # Build an EIP-7702 authorization Source: https://docs.portalhq.io/api-reference/eip-7702/build-an-eip-7702-authorization /openapi/client-api.yaml post /clients/me/chains/{chain}/wallet/build-authorization-list Builds an EIP-7702 authorization hash for the client to sign. This delegates the EOA to a smart contract implementation. # Build an EIP-7702 authorization transaction Source: https://docs.portalhq.io/api-reference/eip-7702/build-an-eip-7702-authorization-transaction /openapi/client-api.yaml post /clients/me/chains/{chain}/wallet/build-authorization-transaction Builds a complete EIP-7702 transaction with the signed authorization. Can optionally be subsidized (gas paid by Portal). # Get account type Source: https://docs.portalhq.io/api-reference/eip-7702/get-account-type /openapi/client-api.yaml get /clients/me/chains/{chain}/wallet/account-type Detects whether the client's wallet on the specified chain is a standard EOA, a smart contract, or an EIP-7702 delegated EOA. # Fund the client's wallet with testnet tokens Source: https://docs.portalhq.io/api-reference/funding/fund-the-clients-wallet-with-testnet-tokens /openapi/client-api.yaml post /clients/me/fund Requests testnet tokens for the client's wallet on a supported testnet chain. Each request funds a caller-specified amount, which must not exceed the per-request maximum for that chain and token. Cumulative successful funding is capped per client, per chain, and per token over a rolling 24-hour window — requests that would exceed the remaining window budget are rejected. The per-request maximum and the 24-hour window budget vary by chain and token. **Supported testnet chains:** Sepolia, Base Sepolia, Monad Testnet, Celo Alfajores, Solana Devnet, Tron Nile, Tron Shasta, Stellar Testnet, Bitcoin Segwit Testnet. # Get the testnet funding configuration Source: https://docs.portalhq.io/api-reference/funding/get-the-testnet-funding-configuration /openapi/client-api.yaml get /clients/me/fund/config Returns the funding configuration for every supported testnet chain and token pair. Each entry describes the per-request maximum and the cumulative maximum allowed over a rolling window, along with the window length in minutes. These are the same limits the `POST /clients/me/fund` endpoint enforces, so you can use this endpoint to discover the supported pairs and their limits before requesting funds. The response is a JSON array of configuration entries. Amounts are returned as decimal strings expressed in whole token units (not base units). # Get gas sponsors Source: https://docs.portalhq.io/api-reference/gas-sponsorship/get-gas-sponsors /openapi/custodian-api.yaml get /custodians/me/gas-sponsorship/chains Retrieves all gas sponsors and their usage information across all chains for your Portal environment. Solana entries also include the gas sponsor's on-chain address and current balance. **Filtering:** Pass the optional `chains` query parameter to narrow the response to a subset of chains. Each entry may be either a friendly chain slug (for example `solana`, `solana-devnet`, `base`, `ethereum`) or a full CAIP-2 chain ID (for example `eip155:1`, `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`). **Tip — speed up your request:** Only need a subset of chains? Passing `chains` skips the data fetches for every chain you leave out, which can reduce response times from several seconds to a few hundred milliseconds. For example, if you only care about Solana gas sponsor info, pass `chains=solana,solana-devnet`. **Note:** Gas sponsorship must be enabled for your Portal environment. # Get historical gas sponsorship usage (deprecated) Source: https://docs.portalhq.io/api-reference/gas-sponsorship/get-historical-gas-sponsorship-usage-deprecated /openapi/custodian-api.yaml get /custodians/me/gas-sponsors/chains/{chainId}/usage **Deprecated.** Use `GET /custodians/me/gas-sponsorship/chains` instead. Retrieves historical gas sponsorship usage by chain for your Portal environment. # Update gas sponsorship for a chain Source: https://docs.portalhq.io/api-reference/gas-sponsorship/update-gas-sponsorship-for-a-chain /openapi/custodian-api.yaml patch /custodians/me/gas-sponsorship/chains/{chainId} Updates the maximum gas allowance limit for a specific chain's gas sponsor. The `value` parameter is in ETH/SOL units. **Note:** Gas sponsorship must be enabled for your Portal environment. # Scan a Solana transaction Source: https://docs.portalhq.io/api-reference/hypernative/scan-a-solana-transaction /openapi/client-api.yaml post /clients/me/integrations/hypernative/solana/scan Scans a Solana transaction for security risks using Hypernative. # Scan a URL Source: https://docs.portalhq.io/api-reference/hypernative/scan-a-url /openapi/client-api.yaml post /clients/me/integrations/hypernative/url/scan Scans a URL for phishing or malicious content using Hypernative. # Scan addresses Source: https://docs.portalhq.io/api-reference/hypernative/scan-addresses /openapi/client-api.yaml post /clients/me/integrations/hypernative/addresses/scan Scans one or more blockchain addresses for risk using Hypernative. # Scan an EIP-712 message Source: https://docs.portalhq.io/api-reference/hypernative/scan-an-eip-712-message /openapi/client-api.yaml post /clients/me/integrations/hypernative/eip-712/scan Scans an EIP-712 typed data message for security risks using Hypernative. # Scan an EVM transaction Source: https://docs.portalhq.io/api-reference/hypernative/scan-an-evm-transaction /openapi/client-api.yaml post /clients/me/integrations/hypernative/evm/scan Scans an EVM transaction for security risks using Hypernative. # Scan NFTs Source: https://docs.portalhq.io/api-reference/hypernative/scan-nfts /openapi/client-api.yaml post /clients/me/integrations/hypernative/nfts/scan Scans one or more NFTs for risk using Hypernative. # Scan tokens Source: https://docs.portalhq.io/api-reference/hypernative/scan-tokens /openapi/client-api.yaml post /clients/me/integrations/hypernative/tokens/scan Scans one or more tokens for risk using Hypernative. # Get a Li.Fi swap/bridge quote Source: https://docs.portalhq.io/api-reference/lifi/get-a-lifi-swapbridge-quote /openapi/client-api.yaml post /clients/me/integrations/lifi/quote Retrieves a quote for a cross-chain or same-chain swap via Li.Fi, including the transaction request to execute. # Get details for a route step Source: https://docs.portalhq.io/api-reference/lifi/get-details-for-a-route-step /openapi/client-api.yaml post /clients/me/integrations/lifi/route-step-details Enriches a route step with full transaction details and updated estimates. Pass a step object from a previous routes response as the request body (not wrapped under any key). # Get Li.Fi supported tokens Source: https://docs.portalhq.io/api-reference/lifi/get-lifi-supported-tokens /openapi/client-api.yaml get /clients/me/integrations/lifi/tokens Retrieves the list of tokens supported by Li.Fi, optionally filtered by chain, chain type, minimum USD price, or tags. # Get Li.Fi swap/bridge routes Source: https://docs.portalhq.io/api-reference/lifi/get-lifi-swapbridge-routes /openapi/client-api.yaml post /clients/me/integrations/lifi/routes Retrieves multiple possible routes for a cross-chain or same-chain swap, each with different trade-offs (speed, cost, safety). # Get Li.Fi transaction status Source: https://docs.portalhq.io/api-reference/lifi/get-lifi-transaction-status /openapi/client-api.yaml get /clients/me/integrations/lifi/status Checks the status of a Li.Fi cross-chain or same-chain swap transaction. # Send a magic link Source: https://docs.portalhq.io/api-reference/magic-links/send-a-magic-link /openapi/authentication-api.yaml post /magic-links Emails a sign-in link to the end user. Portal sends the email from a verified sending domain using one of your email templates. Three of the fields must already be configured in the dashboard: - `redirectUrl` must exactly match one of the environment's Redirect URLs. - `fromEmail` must belong to a verified sending domain that is enabled for this environment. - `templateId` is the Portal template ID from **Authentication > Templates**. `isAccountAbstracted` is optional and decides whether the client Portal creates for this user uses Account Abstraction. # Validate a magic link token Source: https://docs.portalhq.io/api-reference/magic-links/validate-a-magic-link-token /openapi/authentication-api.yaml post /magic-links/validations Exchanges the single-use `token` from a magic link for a session. The response depends on whether the environment requires two-factor authentication. See the `EndUserAuthResult` schema for the three shapes and how to branch on them. # Create Meld customer Source: https://docs.portalhq.io/api-reference/meld/create-meld-customer /openapi/client-api.yaml post /clients/me/integrations/meld/customers Registers the authenticated Portal client as a Meld customer. Returns `409 Conflict` if a Meld customer already exists for this client — use `GET /customers` instead. # Create widget session Source: https://docs.portalhq.io/api-reference/meld/create-widget-session /openapi/client-api.yaml post /clients/me/integrations/meld/retail/widget Creates a Meld widget session for the chosen service provider and returns the embeddable `widgetUrl` plus a single-use session `token`. # Get Meld customer Source: https://docs.portalhq.io/api-reference/meld/get-meld-customer /openapi/client-api.yaml get /clients/me/integrations/meld/customers Looks up the Meld customer registered to the authenticated Portal client. # Get quote Source: https://docs.portalhq.io/api-reference/meld/get-quote /openapi/client-api.yaml post /clients/me/integrations/meld/retail/quote Returns live pricing across available service providers for a buy, sell, or transfer transaction. # Get transaction Source: https://docs.portalhq.io/api-reference/meld/get-transaction /openapi/client-api.yaml get /clients/me/integrations/meld/retail/transactions/{id} Retrieves a single Meld transaction by its Meld transaction ID. # Get transaction by session Source: https://docs.portalhq.io/api-reference/meld/get-transaction-by-session /openapi/client-api.yaml get /clients/me/integrations/meld/retail/transactions/sessions/{sessionId} Retrieves the Meld transaction associated with a widget session ID. # List buy limits Source: https://docs.portalhq.io/api-reference/meld/list-buy-limits /openapi/client-api.yaml get /clients/me/integrations/meld/discovery/buy-limits Lists per-fiat min/max/default purchase amounts. # List countries Source: https://docs.portalhq.io/api-reference/meld/list-countries /openapi/client-api.yaml get /clients/me/integrations/meld/discovery/countries Lists Meld-supported countries (with regions when applicable). # List country defaults Source: https://docs.portalhq.io/api-reference/meld/list-country-defaults /openapi/client-api.yaml get /clients/me/integrations/meld/discovery/defaults Returns the default fiat currency and payment methods per country. # List crypto currencies Source: https://docs.portalhq.io/api-reference/meld/list-crypto-currencies /openapi/client-api.yaml get /clients/me/integrations/meld/discovery/crypto-currencies Lists Meld-supported crypto currencies and their chains. # List fiat currencies Source: https://docs.portalhq.io/api-reference/meld/list-fiat-currencies /openapi/client-api.yaml get /clients/me/integrations/meld/discovery/fiat-currencies Lists Meld-supported fiat currencies. # List KYC limits Source: https://docs.portalhq.io/api-reference/meld/list-kyc-limits /openapi/client-api.yaml get /clients/me/integrations/meld/discovery/kyc-limits Lists daily, weekly, monthly, yearly, and per-transaction limits per KYC tier. # List payment methods Source: https://docs.portalhq.io/api-reference/meld/list-payment-methods /openapi/client-api.yaml get /clients/me/integrations/meld/discovery/payment-methods Lists Meld-supported payment methods. # List sell limits Source: https://docs.portalhq.io/api-reference/meld/list-sell-limits /openapi/client-api.yaml get /clients/me/integrations/meld/discovery/sell-limits Lists per-crypto min/max/default sell amounts. # List service providers Source: https://docs.portalhq.io/api-reference/meld/list-service-providers /openapi/client-api.yaml get /clients/me/integrations/meld/discovery/service-providers Lists Meld service providers available to the current account. # List transactions Source: https://docs.portalhq.io/api-reference/meld/list-transactions /openapi/client-api.yaml get /clients/me/integrations/meld/retail/transactions Lists Meld transactions for the authenticated Portal client. # Back up the client's wallet Source: https://docs.portalhq.io/api-reference/mpc-operations/back-up-the-clients-wallet /openapi/enclave-mpc-api.yaml post /v1/backup Creates backup shares for the wallet. After storing the backup shares, confirm storage with the Client API's update backup share pairs statuses endpoint before attempting to recover. # Create a presignature Source: https://docs.portalhq.io/api-reference/mpc-operations/create-a-presignature /openapi/enclave-mpc-api.yaml post /v1/presign/{curve} Pre-computes MPC signing data for faster subsequent transaction signing. Each presignature can only be used once. You can have up to 100 active presignatures per client. Presignatures currently only support the `SECP256K1` curve (EVM, Bitcoin). ED25519 (Solana) support is coming soon. # Create the client's wallet Source: https://docs.portalhq.io/api-reference/mpc-operations/create-the-clients-wallet /openapi/enclave-mpc-api.yaml post /v1/generate Generates MPC shares for both SECP256K1 (EVM, Bitcoin, etc.) and ED25519 (Solana, XRP, etc.) curves (a Portal wallet). After storing the MPC shares from this endpoint, confirm storage with the Client API's update signing share pairs statuses endpoint before attempting to sign. # Delete a presignature Source: https://docs.portalhq.io/api-reference/mpc-operations/delete-a-presignature /openapi/enclave-mpc-api.yaml delete /v1/presignatures/{id} Deletes a presignature by ID. After deletion, the presignature can no longer be used for signing. For client-stored presignatures, the locally stored opaque `data` blob for that ID also becomes unusable. # List active presignatures Source: https://docs.portalhq.io/api-reference/mpc-operations/list-active-presignatures /openapi/enclave-mpc-api.yaml get /v1/presignatures Lists active presignatures for the authenticated client. The response includes both client-stored and Portal-managed presignatures. Use the `managed` field on each entry to determine whether signing requires the opaque `presignature` data blob or the `presignatureId`. # Recover the client's wallet Source: https://docs.portalhq.io/api-reference/mpc-operations/recover-the-clients-wallet /openapi/enclave-mpc-api.yaml post /v1/recover Recovers signing shares for the wallet using backup shares. After storing the recovered MPC shares, confirm storage with the Client API's update signing share pairs statuses endpoint before attempting to sign. # Send the client's assets Source: https://docs.portalhq.io/api-reference/mpc-operations/send-the-clients-assets /openapi/enclave-mpc-api.yaml post /v1/assets/send Sends a token the client holds to another address. This endpoint builds, signs, and submits a transaction to an RPC gateway. **Important:** A 200 response only confirms that the transaction was received by the RPC gateway — it does not mean the transaction is confirmed on-chain. # Sign a transaction or message by chain Source: https://docs.portalhq.io/api-reference/mpc-operations/sign-a-transaction-or-message-by-chain /openapi/enclave-mpc-api.yaml post /v1/sign Signs a transaction or message using an RPC method and chain ID. The `Idempotency-Key` header is accepted only for broadcast methods (for example `eth_sendTransaction`, `sol_signAndSendTransaction`). Supplying the header with a non-broadcast method returns 400. # Sign a transaction or message by curve Source: https://docs.portalhq.io/api-reference/mpc-operations/sign-a-transaction-or-message-by-curve /openapi/enclave-mpc-api.yaml post /v1/raw/sign/{curve} Signs a raw hex digest using the specified elliptic curve. The `data` field in the response is a hex string **without** the leading `0x`. # Get payout channel form Source: https://docs.portalhq.io/api-reference/noah/get-payout-channel-form /openapi/client-api.yaml get /clients/me/integrations/noah/payouts/channels/{channelId}/form Retrieves dynamic form schema for a payout channel. # Get payout channels Source: https://docs.portalhq.io/api-reference/noah/get-payout-channels /openapi/client-api.yaml get /clients/me/integrations/noah/payouts/channels Retrieves payout channels for a country, crypto currency, and fiat currency. # Get payout countries Source: https://docs.portalhq.io/api-reference/noah/get-payout-countries /openapi/client-api.yaml get /clients/me/integrations/noah/payouts/countries Lists supported payout countries and fiat currencies. # Initiate KYC onboarding Source: https://docs.portalhq.io/api-reference/noah/initiate-kyc-onboarding /openapi/client-api.yaml post /clients/me/integrations/noah/customers/kyc Creates or retrieves a Noah hosted onboarding session for the authenticated client. # Initiate payin Source: https://docs.portalhq.io/api-reference/noah/initiate-payin /openapi/client-api.yaml post /clients/me/integrations/noah/payins Creates a bank-deposit-to-onchain payin workflow and returns bank details. # Initiate payout Source: https://docs.portalhq.io/api-reference/noah/initiate-payout /openapi/client-api.yaml post /clients/me/integrations/noah/payouts Initiates an onchain-deposit-to-payment-method workflow from a prepared payout intent. # List payment methods Source: https://docs.portalhq.io/api-reference/noah/list-payment-methods /openapi/client-api.yaml get /clients/me/integrations/noah/payouts/payment-methods Retrieves Noah payment methods for the authenticated client. # Quote payout Source: https://docs.portalhq.io/api-reference/noah/quote-payout /openapi/client-api.yaml post /clients/me/integrations/noah/payouts/quote Validates payout form input and returns quote details plus a payout intent ID. # Simulate fiat deposit Source: https://docs.portalhq.io/api-reference/noah/simulate-fiat-deposit /openapi/client-api.yaml post /clients/me/integrations/noah/payins/simulate Simulates a fiat deposit for a Noah payment method in sandbox environments. # Get the OAuth authorize URLs Source: https://docs.portalhq.io/api-reference/oauth/get-the-oauth-authorize-urls /openapi/authentication-api.yaml get /oauth/urls Returns the provider authorize URLs to send the end user to. Open the returned URL in a browser to start the sign-in. The response contains a key only for each provider enabled on the environment, so an environment with Google enabled and Apple disabled returns `google` only. An environment with neither enabled returns an empty object. # Validate an OAuth token Source: https://docs.portalhq.io/api-reference/oauth/validate-an-oauth-token /openapi/authentication-api.yaml post /oauth/tokens Exchanges the single-use `token` returned to your `redirectUrl` after a Google or Apple sign-in for a session. The response depends on whether the environment requires two-factor authentication. See the `EndUserAuthResult` schema for the three shapes and how to branch on them. # Get reseller statistics Source: https://docs.portalhq.io/api-reference/reseller-statistics/get-reseller-statistics /openapi/reseller-api.yaml get /resellers/stats Returns all-time wallet generation and completed signature statistics for the authenticated reseller. `totals` summarizes usage across all owned sub-custodians, while `custodians` provides the same metrics for each sub-custodian. Sub-custodians without activity are included with zero values. # Create a sub-custodian API key Source: https://docs.portalhq.io/api-reference/sub-custodian-api-keys/create-a-sub-custodian-api-key /openapi/reseller-api.yaml post /resellers/custodians/{custodianId}/environments/{environmentId}/api-keys Creates an environment-scoped Portal API key for a sub-custodian owned by the authenticated reseller. The raw API key value is returned only in this creation response. # Delete a sub-custodian API key Source: https://docs.portalhq.io/api-reference/sub-custodian-api-keys/delete-a-sub-custodian-api-key /openapi/reseller-api.yaml delete /resellers/custodians/{custodianId}/environments/{environmentId}/api-keys/{apiKeyId} Deletes an environment-scoped Portal API key for a sub-custodian owned by the authenticated reseller. # List sub-custodian environments Source: https://docs.portalhq.io/api-reference/sub-custodian-environments/list-sub-custodian-environments /openapi/reseller-api.yaml get /resellers/custodians/{custodianId}/environments Lists environments for a sub-custodian owned by the authenticated reseller. # Activate a sub-custodian Source: https://docs.portalhq.io/api-reference/sub-custodians/activate-a-sub-custodian /openapi/reseller-api.yaml put /resellers/custodians/{custodianId}/activate Reactivates a suspended sub-custodian owned by the authenticated reseller by setting its billing status to ACTIVE. # Create a sub-custodian Source: https://docs.portalhq.io/api-reference/sub-custodians/create-a-sub-custodian /openapi/reseller-api.yaml post /resellers/custodians Creates a sub-custodian owned by the authenticated reseller. The new custodian is active, non-reseller, assigned the Start-Up plan, created with Development and Production environments, and has Portal backup and Alert webhooks enabled. # Disable or enable signing for a sub-custodian Source: https://docs.portalhq.io/api-reference/sub-custodians/disable-or-enable-signing-for-a-sub-custodian /openapi/reseller-api.yaml patch /resellers/custodians/{custodianId}/capabilities Disables or re-enables a single capability for every client of a sub-custodian owned by the authenticated reseller, across all of its environments. Disabling the `SIGN` capability blocks every signing request from the sub-custodian's clients, even when the request carries a valid client API key or client session token. Unlike suspending the sub-custodian, this leaves its dashboard, Portal API keys and client authentication working, so clients can still back up, recover and eject their wallets. Use it when a sub-custodian should stop transacting but keep access to its wallets, for example while a payment is outstanding. Re-enable signing by sending the same request with `disabled: false`. The custodian-level setting is independent of per-client capabilities set by the sub-custodian itself: clearing it does not re-enable a client the sub-custodian froze individually. Only the reseller can change this setting. The current state is returned in `disabledCapabilities` on every sub-custodian object, including the **List sub-custodians** response. # List sub-custodians Source: https://docs.portalhq.io/api-reference/sub-custodians/list-sub-custodians /openapi/reseller-api.yaml get /resellers/custodians Lists sub-custodians owned by the authenticated reseller. Each custodian includes environments, users, billing plan, billing status, and Portal API key metadata. # Suspend a sub-custodian Source: https://docs.portalhq.io/api-reference/sub-custodians/suspend-a-sub-custodian /openapi/reseller-api.yaml put /resellers/custodians/{custodianId}/suspend Suspends a sub-custodian owned by the authenticated reseller. Suspension blocks that custodian's Portal dashboard users, Portal API keys, client API keys, client session tokens, and service-authenticated requests. # Broadcast a signed transaction Source: https://docs.portalhq.io/api-reference/transactions/broadcast-a-signed-transaction /openapi/client-api.yaml post /clients/me/chains/{chain}/assets/send/broadcast-transaction Broadcasts a signed transaction to the network. Currently only supports Bitcoin (bip122 P2WPKH) chains. Returns 400 for all other chain types. # Build a transaction Source: https://docs.portalhq.io/api-reference/transactions/build-a-transaction /openapi/custodian-api.yaml post /custodians/me/clients/{clientId}/chains/{chain}/assets/send/build-transaction Creates an unsigned transaction for transferring assets to another address on a specific chain. You can then use this unsigned transaction to sign and submit the transaction. The response shape varies depending on the chain type (EIP-155, Solana, Bitcoin, Stellar, or Tron). # Evaluate a transaction for risks Source: https://docs.portalhq.io/api-reference/transactions/evaluate-a-transaction-for-risks /openapi/client-api.yaml post /clients/me/evaluate-transaction Evaluates a transaction using validation and/or simulation to assess risk before signing. Supports both EVM (EIP-155) and Solana chains. The request body format differs by chain type. For **EVM chains**, provide the transaction fields (`to`, `data`, `value`, etc.). For **Solana**, provide an array of base58-encoded transactions. # Validate a TOTP code Source: https://docs.portalhq.io/api-reference/two-factor-authentication/validate-a-totp-code /openapi/authentication-api.yaml post /totps/validations Completes a sign-in that requires two-factor authentication by verifying the six-digit code from the user's authenticator app, and returns the Client Session Token. This endpoint needs two credentials: the `x-portal-auth-environment-id` header, and the `userJwt` from the magic link or OAuth validation response sent as a Bearer token. The `userJwt` may also be passed in the request body instead of the `Authorization` header. On the user's first successful verification, enrollment is completed and subsequent sign-ins no longer return a `totpLink`. # Get asset balances Source: https://docs.portalhq.io/api-reference/wallet-metadata/get-asset-balances /openapi/custodian-api.yaml get /custodians/me/clients/{clientId}/chains/{chain}/assets Retrieves the native balance, token balances, and NFTs for a specified client and blockchain. # Get NFTs Source: https://docs.portalhq.io/api-reference/wallet-metadata/get-nfts /openapi/custodian-api.yaml get /custodians/me/clients/{clientId}/chains/{chain}/assets/nfts Retrieves the NFTs held by a specified client on a given blockchain. # Complete wallet ejection Source: https://docs.portalhq.io/api-reference/wallet-shares/complete-wallet-ejection /openapi/client-api.yaml patch /clients/me/wallets/{walletId}/complete-eject Completes the ejection process for a wallet. # Delete a signing share pair Source: https://docs.portalhq.io/api-reference/wallet-shares/delete-a-signing-share-pair /openapi/client-api.yaml delete /clients/me/wallets/{walletId}/signing-share-pairs/{signingSharePairId} Deletes a specific signing share pair from a wallet. # Enable wallet ejection Source: https://docs.portalhq.io/api-reference/wallet-shares/enable-wallet-ejection /openapi/custodian-api.yaml patch /custodians/me/clients/{clientId}/enable-eject Prepare a wallet for ejection of its private keys. This endpoint enables a Portal client to be ejected for a specified duration. **Your custodian must be using Portal-Managed Backups to use this endpoint.** # Get backup share pairs Source: https://docs.portalhq.io/api-reference/wallet-shares/get-backup-share-pairs /openapi/client-api.yaml get /clients/me/wallets/{walletId}/backup-share-pairs Retrieves the backup share pairs for a specific wallet. # Get ejectable backup shares Source: https://docs.portalhq.io/api-reference/wallet-shares/get-ejectable-backup-shares /openapi/custodian-api.yaml get /custodians/me/clients/{clientId}/wallets/{walletId}/ejectable-backup-shares Fetches a client's encrypted backup share (if previously sent to Portal) and their custodian backup share. Used when running eject while using the Enclave MPC API with a Portal-Managed Backup configuration. **Your custodian must be using Portal-Managed Backups to use this endpoint.** # Get ejectable backup shares for a wallet Source: https://docs.portalhq.io/api-reference/wallet-shares/get-ejectable-backup-shares-for-a-wallet /openapi/client-api.yaml get /clients/me/wallets/{walletId}/ejectable-backup-shares Retrieves the ejectable backup shares for a wallet that is in an ejectable state. The wallet must have `ejectableUntil` set and not expired. On successful retrieval, the `ejectableUntil` field is cleared (side effect). # Get signing share pairs Source: https://docs.portalhq.io/api-reference/wallet-shares/get-signing-share-pairs /openapi/client-api.yaml get /clients/me/wallets/{walletId}/signing-share-pairs Retrieves the signing share pairs for a specific wallet. # Prepare a client for eject (deprecated) Source: https://docs.portalhq.io/api-reference/wallet-shares/prepare-a-client-for-eject-deprecated /openapi/custodian-api.yaml post /custodians/me/clients/{clientId}/prepare-eject **Deprecated.** Use the `enable-eject` endpoint instead. Prepare a wallet for ejection of its private keys. # Prepare wallet ejection (deprecated) Source: https://docs.portalhq.io/api-reference/wallet-shares/prepare-wallet-ejection-deprecated /openapi/client-api.yaml post /clients/me/wallets/{walletId}/prepare-eject **Deprecated.** Use the ejectable-backup-shares endpoint instead. Prepares the wallet for ejection by retrieving backup shares. # Update backup share pair statuses Source: https://docs.portalhq.io/api-reference/wallet-shares/update-backup-share-pair-statuses /openapi/client-api.yaml patch /clients/me/backup-share-pairs Updates the status of one or more backup share pairs. Valid target statuses: - `STORED_CLIENT_BACKUP_SHARE_KEY` — requires current status to be `STORED_CUSTODIAN_BACKUP_SHARE` - `STORED_CLIENT_BACKUP_SHARE` — accepts current status of `STORED_CUSTODIAN_BACKUP_SHARE` or `STORED_CLIENT_BACKUP_SHARE_KEY` # Update signing share pair statuses Source: https://docs.portalhq.io/api-reference/wallet-shares/update-signing-share-pair-statuses /openapi/client-api.yaml patch /clients/me/signing-share-pairs Updates the status of one or more signing share pairs to `STORED_CLIENT`. This confirms that the client has stored its signing share locally. # Get transaction details by signature Source: https://docs.portalhq.io/api-reference/wallet/get-transaction-details-by-signature /openapi/client-api.yaml get /clients/me/chains/{chain}/transactions/{signature} Look up a transaction or user operation by its hash or signature. Supports EVM (transaction hash or user operation hash), Solana (signature), Bitcoin (txid), Stellar (hash), and Tron (txid). # Get wallet balances Source: https://docs.portalhq.io/api-reference/wallet/get-wallet-balances /openapi/client-api.yaml get /clients/me/chains/{chain}/assets Retrieves the native balance, token balances, and optionally NFTs for the client's wallet on the specified chain. # Get wallet capabilities Source: https://docs.portalhq.io/api-reference/wallet/get-wallet-capabilities /openapi/client-api.yaml get /clients/me/wallet_getCapabilities Returns the EIP-5792 wallet capabilities for the client. If the client uses account abstraction, returns the supported capabilities (e.g. paymaster service) keyed by hex chain ID. Returns an empty object if account abstraction is not enabled. # Get wallet NFTs Source: https://docs.portalhq.io/api-reference/wallet/get-wallet-nfts /openapi/client-api.yaml get /clients/me/chains/{chain}/assets/nfts Retrieves all NFTs owned by the client's wallet on the specified chain. Only returns results for EVM (EIP-155) chains. Returns an empty array for non-EVM chains. # Get wallet transactions Source: https://docs.portalhq.io/api-reference/wallet/get-wallet-transactions /openapi/client-api.yaml get /clients/me/chains/{chain}/transactions Retrieves transaction history for the client's wallet on the specified chain. Supports EVM (EIP-155), Solana, Bitcoin, Tron, and Stellar chains. **Response format varies by chain:** - **Solana** returns the legacy format with `signature`, `blockTime`, `tokenMint`, and nested `transactionDetails` (containing `transaction`, `signatureDetails`, `metadata`). This format will be migrated to the unified format in a future release. - **All other chains** (EVM, Bitcoin, Tron, Stellar) return the unified `TransactionHistoryItem` format with `hash`, `from`, `to`, `value`, `blockNumber`, `blockTimestamp`, `status`, `chainId`, and optional token/UserOp fields. **Tron:** the history includes native TRX transfers and TRC-20 token transfers (for example USDT). Addresses are base58 (`T...`), `value` is in TRX or in whole token units, and TRC-20 items carry `asset`, `tokenAddress`, and `tokenDecimals`. TRC-20 items have no `blockNumber`. For EVM chains with Account Abstraction enabled, UserOperations can be included, excluded, or fetched exclusively using the `userOperations` parameter. UserOperation items in the response include the bundler transaction hash, sender, entry point, gas costs, and `userOpHash` — but not the decoded `to`, `value`, or `callData`. To retrieve full UserOperation details, use the single transaction lookup endpoint (`GET /clients/me/chains/{chain}/transactions/{userOpHash}`). **Note:** For EVM chains, this endpoint returns asset transfer history (native tokens, ERC-20, ERC-721, ERC-1155). Contract interactions that do not transfer assets — such as approvals, delegations, or governance votes — will not appear in the results. UserOperations from AA wallets are an exception and will appear regardless of whether they involve a token transfer. # Execute a borrow action Source: https://docs.portalhq.io/api-reference/yieldxyz-borrow/execute-a-borrow-action /openapi/client-api.yaml post /clients/me/integrations/yield-xyz-borrow/actions Generate unsigned transactions for a lending/borrowing action. Returns transaction(s) to sign. Submit each signed transaction via POST /transactions/:id/submit. For async multi-step flows (e.g., cross-chain bridges), hasNextStep will be true — call POST /actions/:id/step after confirmation to get the next transaction(s). This is a proxy to the Yield.xyz Borrow `POST /v1/actions` endpoint. Portal validates the upstream response against the schema documented here before returning it under `data`, and translates `network` values to CAIP-2. If Yield.xyz changes the response shape, Portal returns a 500 with `id: INTEGRATION_RESPONSE_SCHEMA_DRIFT` instead of a partial response. Upstream reference: [docs.yield.xyz](https://docs.yield.xyz/reference/actionscontroller_executeaction_v1). # Get action by ID Source: https://docs.portalhq.io/api-reference/yieldxyz-borrow/get-action-by-id /openapi/client-api.yaml get /clients/me/integrations/yield-xyz-borrow/actions/{id} Retrieve detailed information about a specific action including current status, transactions, and step progress. This is a proxy to the Yield.xyz Borrow `GET /v1/actions/{id}` endpoint. Portal validates the upstream response against the schema documented here before returning it under `data`, and translates `network` values to CAIP-2. If Yield.xyz changes the response shape, Portal returns a 500 with `id: INTEGRATION_RESPONSE_SCHEMA_DRIFT` instead of a partial response. Upstream reference: [docs.yield.xyz](https://docs.yield.xyz/reference/actionscontroller_getaction_v1). # Get integration details Source: https://docs.portalhq.io/api-reference/yieldxyz-borrow/get-integration-details /openapi/client-api.yaml get /clients/me/integrations/yield-xyz-borrow/integrations/{integrationId} Retrieve detailed information about a specific lending/borrowing integration This is a proxy to the Yield.xyz Borrow `GET /v1/integrations/{integrationId}` endpoint. Portal validates the upstream response against the schema documented here before returning it under `data`, and translates `network` values to CAIP-2. If Yield.xyz changes the response shape, Portal returns a 500 with `id: INTEGRATION_RESPONSE_SCHEMA_DRIFT` instead of a partial response. Upstream reference: [docs.yield.xyz](https://docs.yield.xyz/reference/integrationscontroller_getintegration_v1). # Get liquidation history Source: https://docs.portalhq.io/api-reference/yieldxyz-borrow/get-liquidation-history /openapi/client-api.yaml get /clients/me/integrations/yield-xyz-borrow/positions/liquidations Retrieve a paginated history of Morpho Blue liquidation events for a borrower address, read from the indexed store. This is the only place a fully-liquidated (closed) position can be retrieved, since it no longer appears in GET /v1/positions. Ordered by most recent first. This is a proxy to the Yield.xyz Borrow `GET /v1/positions/liquidations` endpoint. Portal validates the upstream response against the schema documented here before returning it under `data`, and translates `network` values to CAIP-2. If Yield.xyz changes the response shape, Portal returns a 500 with `id: INTEGRATION_RESPONSE_SCHEMA_DRIFT` instead of a partial response. Upstream reference: [docs.yield.xyz](https://docs.yield.xyz/reference/positionscontroller_getliquidations_v1). # Get market by ID Source: https://docs.portalhq.io/api-reference/yieldxyz-borrow/get-market-by-id /openapi/client-api.yaml get /clients/me/integrations/yield-xyz-borrow/markets/{marketId} Retrieve details for a specific lending market. Useful for deep linking into a specific market (e.g., cbBTC/USDT on Morpho Blue). This is a proxy to the Yield.xyz Borrow `GET /v1/markets/{marketId}` endpoint. Portal validates the upstream response against the schema documented here before returning it under `data`, and translates `network` values to CAIP-2. If Yield.xyz changes the response shape, Portal returns a 500 with `id: INTEGRATION_RESPONSE_SCHEMA_DRIFT` instead of a partial response. Upstream reference: [docs.yield.xyz](https://docs.yield.xyz/reference/marketscontroller_getmarketbyid_v1). # Get next step transactions Source: https://docs.portalhq.io/api-reference/yieldxyz-borrow/get-next-step-transactions /openapi/client-api.yaml post /clients/me/integrations/yield-xyz-borrow/actions/{id}/step For async multi-step actions (e.g., cross-chain bridges, delayed withdrawals), retrieve the next transaction(s) after the previous step has been confirmed on-chain. Call this when hasNextStep is true on the action response. This is a proxy to the Yield.xyz Borrow `POST /v1/actions/{id}/step` endpoint. Portal validates the upstream response against the schema documented here before returning it under `data`, and translates `network` values to CAIP-2. If Yield.xyz changes the response shape, Portal returns a 500 with `id: INTEGRATION_RESPONSE_SCHEMA_DRIFT` instead of a partial response. Upstream reference: [docs.yield.xyz](https://docs.yield.xyz/reference/actionscontroller_step_v1). # Get positions Source: https://docs.portalhq.io/api-reference/yieldxyz-borrow/get-positions /openapi/client-api.yaml get /clients/me/integrations/yield-xyz-borrow/positions Retrieve all supply and borrow positions for a user address across specified integration and network. This is a proxy to the Yield.xyz Borrow `GET /v1/positions` endpoint. Portal validates the upstream response against the schema documented here before returning it under `data`, and translates `network` values to CAIP-2. If Yield.xyz changes the response shape, Portal returns a 500 with `id: INTEGRATION_RESPONSE_SCHEMA_DRIFT` instead of a partial response. Upstream reference: [docs.yield.xyz](https://docs.yield.xyz/reference/positionscontroller_getpositions_v1). # Health check Source: https://docs.portalhq.io/api-reference/yieldxyz-borrow/health-check /openapi/client-api.yaml get /clients/me/integrations/yield-xyz-borrow/health Get the health status of the borrow API with current timestamp This is a proxy to the Yield.xyz Borrow `GET /health` endpoint. Portal validates the upstream response against the schema documented here before returning it under `data`, and translates `network` values to CAIP-2. If Yield.xyz changes the response shape, Portal returns a 500 with `id: INTEGRATION_RESPONSE_SCHEMA_DRIFT` instead of a partial response. Upstream reference: [docs.yield.xyz](https://docs.yield.xyz/reference/healthcontroller_health-2). # List actions Source: https://docs.portalhq.io/api-reference/yieldxyz-borrow/list-actions /openapi/client-api.yaml get /clients/me/integrations/yield-xyz-borrow/actions Get a paginated list of actions, with optional filtering by address, integration, status, etc. This is a proxy to the Yield.xyz Borrow `GET /v1/actions` endpoint. Portal validates the upstream response against the schema documented here before returning it under `data`, and translates `network` values to CAIP-2. If Yield.xyz changes the response shape, Portal returns a 500 with `id: INTEGRATION_RESPONSE_SCHEMA_DRIFT` instead of a partial response. Upstream reference: [docs.yield.xyz](https://docs.yield.xyz/reference/actionscontroller_getactions_v1). # List integrations Source: https://docs.portalhq.io/api-reference/yieldxyz-borrow/list-integrations /openapi/client-api.yaml get /clients/me/integrations/yield-xyz-borrow/integrations Retrieve a list of available lending/borrowing integrations (e.g., Aave, Spark, Morpho) with their supported actions and networks. This is a proxy to the Yield.xyz Borrow `GET /v1/integrations` endpoint. Portal validates the upstream response against the schema documented here before returning it under `data`, and translates `network` values to CAIP-2. If Yield.xyz changes the response shape, Portal returns a 500 with `id: INTEGRATION_RESPONSE_SCHEMA_DRIFT` instead of a partial response. Upstream reference: [docs.yield.xyz](https://docs.yield.xyz/reference/integrationscontroller_getintegrations_v1). # List lending markets Source: https://docs.portalhq.io/api-reference/yieldxyz-borrow/list-lending-markets /openapi/client-api.yaml get /clients/me/integrations/yield-xyz-borrow/markets Retrieve a paginated list of available lending markets across all supported integrations and networks. Each market represents a token that can be supplied or borrowed. This is a proxy to the Yield.xyz Borrow `GET /v1/markets` endpoint. Portal validates the upstream response against the schema documented here before returning it under `data`, and translates `network` values to CAIP-2. If Yield.xyz changes the response shape, Portal returns a 500 with `id: INTEGRATION_RESPONSE_SCHEMA_DRIFT` instead of a partial response. Upstream reference: [docs.yield.xyz](https://docs.yield.xyz/reference/marketscontroller_getmarkets_v1). # Submit signed transaction Source: https://docs.portalhq.io/api-reference/yieldxyz-borrow/submit-signed-transaction /openapi/client-api.yaml post /clients/me/integrations/yield-xyz-borrow/transactions/{transactionId}/submit Submit a signed transaction. Provide signedPayload to have us broadcast it to the blockchain, or transactionHash if already submitted by the client. This is a proxy to the Yield.xyz Borrow `POST /v1/transactions/{transactionId}/submit` endpoint. Portal validates the upstream response against the schema documented here before returning it under `data`, and translates `network` values to CAIP-2. If Yield.xyz changes the response shape, Portal returns a 500 with `id: INTEGRATION_RESPONSE_SCHEMA_DRIFT` instead of a partial response. Upstream reference: [docs.yield.xyz](https://docs.yield.xyz/reference/transactionscontroller_submittransaction_v1). # Enter a yield position Source: https://docs.portalhq.io/api-reference/yieldxyz/enter-a-yield-position /openapi/client-api.yaml post /clients/me/integrations/yield-xyz/actions/enter Builds transactions for entering a yield position (staking, lending, or vault deposit). This is a proxy to the Yield.xyz `POST /actions/enter` endpoint. The response is wrapped under `data.rawResponse` and `network` fields on transactions are converted to CAIP-2 format. For Solana yields where a gas sponsor is configured for the environment, `arguments.feePayerAddress` is auto-populated. For the full upstream response schema see [docs.yield.xyz](https://docs.yield.xyz/reference/actionscontroller_enteryield). # Exit a yield position Source: https://docs.portalhq.io/api-reference/yieldxyz/exit-a-yield-position /openapi/client-api.yaml post /clients/me/integrations/yield-xyz/actions/exit Builds transactions for exiting a yield position (unstaking, withdrawing, or redeeming). This is a proxy to the Yield.xyz `POST /actions/exit` endpoint. The response is wrapped under `data.rawResponse` and `network` fields on transactions are converted to CAIP-2 format. For Solana yields where a gas sponsor is configured for the environment, `arguments.feePayerAddress` is auto-populated. For the full upstream response schema see [docs.yield.xyz](https://docs.yield.xyz/reference/actionscontroller_exityield). # Get a yield transaction Source: https://docs.portalhq.io/api-reference/yieldxyz/get-a-yield-transaction /openapi/client-api.yaml get /clients/me/integrations/yield-xyz/transactions/{transactionId} Retrieves the status and details of a specific yield transaction. This is a proxy to the Yield.xyz `GET /transactions/{transactionId}` endpoint. The response is wrapped under `data.rawResponse` and `network` fields are converted to CAIP-2 format. For the full upstream response schema see [docs.yield.xyz](https://docs.yield.xyz/reference/transactionscontroller_gettransaction). # Get default yield opportunities Source: https://docs.portalhq.io/api-reference/yieldxyz/get-default-yield-opportunities /openapi/client-api.yaml get /clients/me/integrations/yield-xyz/yields/defaults Returns Portal's recommended yield opportunities per chain and token. By default, returns only the yield IDs (lightweight). Pass `includeOpportunities=true` to enrich each entry with live data (APY, status, mechanics, etc.) from Yield.xyz. Entries with no recommendation return `null` for both `yieldId` and `opportunity`. # Get yield action history Source: https://docs.portalhq.io/api-reference/yieldxyz/get-yield-action-history /openapi/client-api.yaml get /clients/me/integrations/yield-xyz/actions Retrieves a paginated list of yield actions performed by the client. This is a proxy to the Yield.xyz `GET /actions` endpoint. The response is wrapped under `data.rawResponse` and `network` fields on transactions are converted to CAIP-2 format. For the full upstream response schema see [docs.yield.xyz](https://docs.yield.xyz/reference/actionscontroller_getactions). # Get yield balances Source: https://docs.portalhq.io/api-reference/yieldxyz/get-yield-balances /openapi/client-api.yaml post /clients/me/integrations/yield-xyz/yields/balances Retrieves yield balances for the client's positions across staking, lending, and vault opportunities. This is a proxy to the Yield.xyz `POST /yields/balances` endpoint. The response is wrapped under `data.rawResponse` and `network` fields are converted to CAIP-2 format. For the full upstream response schema see [docs.yield.xyz](https://docs.yield.xyz/reference/yieldscontroller_getaggregatebalances). # Get yield opportunities Source: https://docs.portalhq.io/api-reference/yieldxyz/get-yield-opportunities /openapi/client-api.yaml get /clients/me/integrations/yield-xyz/yields Retrieves a paginated list of available yield opportunities across all supported networks and protocols. This is a proxy to the Yield.xyz `GET /yields` endpoint. The response is wrapped under `data.rawResponse` and `network` fields are converted to CAIP-2 format. For the full upstream response schema see [docs.yield.xyz](https://docs.yield.xyz/reference/yieldscontroller_getyields). # Get yield validators Source: https://docs.portalhq.io/api-reference/yieldxyz/get-yield-validators /openapi/client-api.yaml get /clients/me/integrations/yield-xyz/yields/{yieldId}/validators Retrieves a paginated list of validators available for staking or delegation for a specific yield opportunity. Required for yields where `mechanics.requiresValidatorSelection` is `true` (e.g. native staking). Pass the chosen validator's address as `validatorAddress` in the enter action arguments. This is a proxy to the Yield.xyz `GET /yields/{yieldId}/validators` endpoint. For the full upstream response schema see [docs.yield.xyz](https://docs.yield.xyz/reference/yieldscontroller_getyieldvalidators). # Manage a yield position Source: https://docs.portalhq.io/api-reference/yieldxyz/manage-a-yield-position /openapi/client-api.yaml post /clients/me/integrations/yield-xyz/actions/manage Builds transactions for managing an existing yield position, such as claiming rewards, restaking, or withdrawing unstaked tokens. This is a proxy to the Yield.xyz `POST /actions/manage` endpoint. The response is wrapped under `data.rawResponse` and `network` fields on transactions are converted to CAIP-2 format. For the full upstream response schema see [docs.yield.xyz](https://docs.yield.xyz/reference/actionscontroller_manageyield). # Submit a transaction hash Source: https://docs.portalhq.io/api-reference/yieldxyz/submit-a-transaction-hash /openapi/client-api.yaml put /clients/me/integrations/yield-xyz/transactions/{transactionId}/submit-hash Submits the on-chain transaction hash for a pending yield transaction after signing and broadcasting it. If `chainId` is provided alongside a user-operation hash, Portal resolves the underlying transaction hash before forwarding to Yield.xyz. This is a proxy to the Yield.xyz `PUT /transactions/{transactionId}/submit-hash` endpoint. The response is wrapped under `data.rawResponse`. For the full upstream response schema see [docs.yield.xyz](https://docs.yield.xyz/reference/transactionscontroller_submittransactionhash). # API Reference Source: https://docs.portalhq.io/apis/api-reference-overview Explore Portal's REST APIs for MPC wallet operations, client-side actions, custodian management, and reseller workflows. Portal exposes REST APIs for each role in the wallet lifecycle. All APIs use bearer token authentication via the `Authorization` header. **Base URL:** `https://mpc-client.portalhq.io` Core cryptographic operations — wallet generation, backup, recovery, and transaction signing. This is the lowest-level API and handles all MPC operations directly. * **Wallet Management** — Generate, backup, and recover wallets * **Signing** — Send assets, sign messages, and presign transactions **Base URL:** `https://api.portalhq.io/api/v3` Everything a client (end user) can do with their wallet — view balances, send transactions, manage shares, swap tokens, and more. * **Clients** — Client details and eject * **Transactions** — Build, broadcast, simulate, and fund * **Wallet Metadata** — Assets, NFTs, and transaction history * **Wallet Shares** — Signing and backup share management * **Delegations** — EVM and Solana delegation operations * **Session Keys** — Add and revoke session keys * **EIP-7702** — Account type and authorization transactions * **Li.Fi** — Cross-chain routing and bridging * **0x** — Token swap quotes and pricing * **Yield.xyz** — Yield positions and actions via Yield.xyz * **Blockaid** — Transaction and address security scanning * **Hypernative** — Threat detection for transactions, addresses, and URLs **Base URL:** `https://api.portalhq.io/api/v3/auth` Sign your end users in with an email magic link, Google, or Apple, and receive a Client Session Token for their client. Identified by the `x-portal-auth-environment-id` header rather than a bearer token. * **Auth Methods** — Discover the sign-in methods an environment has enabled * **Magic Links** — Send and validate email magic links * **OAuth** — Sign in with Google or Apple * **Two-Factor Authentication** — Validate a TOTP code **Base URL:** `https://api.portalhq.io/api/v3` Manage clients and their wallets on their behalf — create clients, build transactions, configure alerts, and sponsor gas. * **Clients** — Create clients, sessions, and web OTPs * **Transactions** — Build transactions on behalf of clients * **Wallet Metadata** — View client assets and NFTs * **Wallet Shares** — Enable and prepare eject for clients * **Delegations** — View delegation status and transfer * **Alert Webhooks** — Configure and manage alert delivery * **Gas Sponsorship** — View and update gas sponsor settings **Base URL:** `https://api.portalhq.io/api/v3` Create and manage sub-custodians on behalf of a reseller custodian. * **Reseller Statistics** — View aggregate and per-sub-custodian usage statistics * **Sub-Custodians** — Create, list, suspend, and reactivate sub-custodians * **Sub-Custodian Environments** — View sub-custodian environments * **Sub-Custodian API Keys** — Create and delete sub-custodian Portal API keys # API Reference Source: https://docs.portalhq.io/apis/authentication/api-reference-link Interactive API reference for the Authentication API endpoints. View the full interactive Authentication API reference. # API Reference Source: https://docs.portalhq.io/apis/client/api-reference-link Interactive API reference for the Client API endpoints. View the full interactive Client API reference. # API Reference Source: https://docs.portalhq.io/apis/custodian/api-reference-link Interactive API reference for the Custodian API endpoints. View the full interactive Custodian API reference. # API Reference Source: https://docs.portalhq.io/apis/enclave-mpc/api-reference-link Interactive API reference for the Enclave MPC API endpoints. View the full Enclave MPC API reference. # Back up a wallet Source: https://docs.portalhq.io/apis/enclave-mpc/guide/backup This guide will walk you through how to create a backup of a Portal client's wallet using the Enclave MPC API. # Back up a wallet Portal lets you securely back up your MPC wallets in order to recover them even if the signing share pairs are lost or damaged. There are two configurations you can choose from when running backup: **Portal-Managed Backups** or **Self-Managed Backups** which alter where backup shares are stored. ## Portal-Managed Backups With Portal-Managed Backups, Portal will store the Custodian Backup Shares on our backend leaving the storage of the Client Backup Shares up to your application. This means you do **not** need to build and maintain a webhook server that accepts Custodian Backup Shares from Portal. The benefit of Portal-Managed Backups is that there is less integration work required. The tradeoff is that you are dependent on Portal for running backup & recovery. When using Portal-Managed Backups with the Enclave MPC API you will save the Client Backup Share in your backend. Be sure to store the shares in a way that makes them resilient to failure scenarios of your Client Signing Shares. This implementation means that for recovery or eject you will retrieve the Client Backup Share from your own backend while requesting the Custodian Backup Share from Portal. ## Self-Managed Backups With Self-Managed Backups, Portal will send the Custodian Backup Shares to a webhook server you're required to deploy and maintain while your application is responsible for storing the Client Backup Share. See our [Self-Managed Backups](../../../resources/self-managed-backups) guide for more details on implementation. ## Implementing Backup While using the SDKs provides 4 built in backup methods: GDrive, iCloud, Password/PIN, and Passkeys. However, when using the Enclave MPC API **your application is responsible for its own backup method**. We recommend using a Key Management Service (KMS) like those in [Google Cloud](https://cloud.google.com/kms/docs/) and [AWS](https://docs.aws.amazon.com/kms/latest/developerguide/overview.html) paired with [envelope encryption](https://cloud.google.com/kms/docs/envelope-encryption) using AES 256 in GCM. #### Example ```ts theme={null} // Step 1. Run backup by passing in the response from generate (the signing shares) const backupResponse = await axios.post( `https://mpc-client.portalhq.io/v1/backup`, { generateResponse: JSON.stringify(signingShares), }, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${clientApiKey}`, }, }, ); const { secp256k1Share, ed25519Share } = backupResponse.data; // Step 2. Encrypt the client backup shares const secp256k1Ciphertext = await YourEnvelopEncryptionService.encrypt(secp256k1Share) const ed25519Ciphertext = await YourEnvelopEncryptionService.encrypt(ed25519Share) // Step 3. Store the client ciphertexts with Portal or your backend [OPTIONAL] await axios.post( `https://api.portalhq.io/api/v3/clients/me/backup-share-pairs/${secp256k1Share.id}`, { clientCipherText: secp256k1Ciphertext, }, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${clientApiKey}`, }, }, ); await axios.post(`https://api.portalhq.io/api/v3/clients/me/backup-share-pairs/${ed25519Share.id}`, { clientCipherText: ed25519Ciphertext, }, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${clientApiKey}`, }, }, ); // Step 4. Mark the backup process as complete by updating the backup share pairs status await axios.patch( `https://api.portalhq.io/api/v3/clients/me/backup-share-pairs`, { status: 'STORED_CLIENT_BACKUP_SHARE', backupSharePairIds: [secp256k1Share.id, ed25519Share.id], }, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${clientApiKey}`, }, }, ); ``` ```ts theme={null} // Step 1. Run backup by passing in the response from generate (the signing shares) const backupResponse = await axios.post( `https://mpc-client.portalhq.io/v1/backup`, { generateResponse: JSON.stringify(signingShares), }, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${clientApiKey}`, }, }, ); const { secp256k1Share, ed25519Share } = backupResponse.data; // Step 2. Encrypt the client backup shares const secp256k1Ciphertext = await YourEnvelopEncryptionService.encrypt(secp256k1Share) const ed25519Ciphertext = await YourEnvelopEncryptionService.encrypt(ed25519Share) // Step 3. Store the client ciphertexts on your own backend await axios.post('{your_server}/users/[userId]/user-backup-share', { data: { secp256k1Ciphertext, ed25519Ciphertext } }) // Step 4. Mark the backup process as complete by updating the backup share pairs status await axios.patch( `https://api.portalhq.io/api/v3/clients/me/backup-share-pairs`, { status: 'STORED_CLIENT_BACKUP_SHARE', backupSharePairIds: [secp256k1Share.id, ed25519Share.id], }, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${clientApiKey}`, }, }, ); ``` # Concurrent Transactions Source: https://docs.portalhq.io/apis/enclave-mpc/guide/concurrent-transactions This guide will walk you through how to submit multiple transactions with sequential nonces using the Enclave MPC API. In some cases, you might want to submit multiple transactions that will be processed in sequence by the network. This is useful for scenarios like batch operations or when you need to ensure transactions are processed in a specific order. Here's how to submit multiple transactions with sequential nonces. ## Understanding Nonces Before submitting concurrent transactions, it's important to understand nonces: * Every Ethereum transaction requires a nonce * Nonces must be used in sequence (0, 1, 2, etc.) * The network will process transactions in nonce order * Multiple pending transactions can exist in the mempool **as long as they have** **sequential nonces** * Submitting multiple transactions with the same nonce will result in the transaction with the highest gas overriding others ## Steps 1. First, retrieve your current nonce from the network. This ensures you start with the correct sequence number. ```ts theme={null} const web3 = new Web3("YOUR_RPC_URL"); const currentNonce = await web3.eth.getTransactionCount(walletAddress); ``` 2. Format your transaction objects with incrementing nonces. Here's an example transaction format: ```ts theme={null} const transactionParams = { nonce: "0x" + nonce.toString(16), // Convert nonce to hex value: "0x1", from: "YOUR_ETH_ADDRESS", to: "RECIPIENT_ADDRESS", data: "0x", maxFeePerGas: "0x2540BE400", // 10 Gwei maxPriorityFeePerGas: "0x3B9ACA00", // 1 Gwei gasLimit: "0x5208" // 21000 }; ``` 3. Submit multiple transactions using the Portal API. Each request should use the `/v1/sign` endpoint with `eth_sendTransaction` method: ```bash theme={null} curl -X POST 'https://mpc-client.portalhq.io/v1/sign' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer ' \ -d '{ "share": $share, "method": "eth_sendTransaction", "params": "{\"nonce\": \"0x0\", \"value\": \"0x01\", \"from\": \"YOUR_ETH_ADDRESS\", \"to\": \"RECIPIENT_ADDRESS\", \"data\": \"0x\", \"maxFeePerGas\": \"0x2540BE400\", \"maxPriorityFeePerGas\": \"0x3B9ACA00\", \"gasLimit\": \"0x5208\"}", "rpcUrl": "YOUR_RPC_URL", "chainId": "eip155:10143" }' ``` 4. Repeat the request for each transaction, incrementing the nonce each time. ## Important Notes * All transactions will enter the mempool but may not be processed immediately * Transactions will be processed in nonce order, regardless of submission time * If a transaction with a lower nonce fails, subsequent transactions will remain pending * Consider adding delays between submissions to prevent rate limiting * Make sure your wallet has sufficient funds to cover all transactions If any transaction in the sequence fails due to insufficient funds or other errors, subsequent transactions with higher nonces will remain pending until the issue is resolved. # Create a client Source: https://docs.portalhq.io/apis/enclave-mpc/guide/create-a-client This guide will walk you through how to create a Portal client using the Enclave MPC API. In order to create a wallet, we first need to create a Portal **client**. Although in your production environment you will be doing this programmatically via the [new Client](../reference#create-a-new-client) REST API endpoint, for testing purposes you can head to the Portal Admin Dashboard to create a test client. A Portal **client** represents a single user. You use the Portal client as a 1:1 with users on your backend. ### Steps 1. Log in to the [Portal Admin Dashboard](https://app.portalhq.io). If don’t have access reach out to us to [request trial access](https://www.portalhq.io/book-demo)! 2. After you login, under "Configuration" on the left hand side of the page, click on "API Keys". 3. Once you have located the "Test Client API Keys" section, click on "New +". This will generate a test **client** with a **Client API Key** - test credentials for development purposes. 4. After clicking the "New +" button, this will pop open a window displaying the Client API Key and the Client ID. Go ahead and copy the Client API Key. Congrats! 🎉 You have now created your first Portal client and are ready to generate a wallet! # Create a wallet Source: https://docs.portalhq.io/apis/enclave-mpc/guide/create-a-wallet # Create a wallet To create a wallet we are going to use the `/v1/generate` endpoint to create a new wallet and then use the `/v3/clients/me/signing-share-pairs` endpoint to confirm successful storage of the generated signing share. Worth nothing that the generate endpoint returns MPC shares for both the `SECP256K1` curve (compatible with EVM and Bitcoin chains) and the `ED25519` curve (compatible with Solana). In other words, you only need to make one generate request to get wallets for multiple chains! ### Steps 1. From your application, make an HTTP POST request to `https://mpc-client.portalhq.io/v1/generate` using the `clientApiKey` you received in the previous step as the Bearer token. ```bash theme={null} curl -X POST 'https://mpc-client.portalhq.io/v1/generate' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer ' \ -d '{}' ``` After sending this request you should get a `200` status code with a JSON response that looks like: ```json theme={null} { "secp256k1": { "share": "eyJjbGllbnRJZCI6IiIsImJhY2t1cFNoYXJlUGFpcklkIjoiIiwic2lnbml...", "id": "clu3aue3j001fs60wdecyz0qy" }, "ed25519": { "share": "zMzMzQ4MzIyMDUwMDYwMDc1NzU2NDYzMTU1MzEyODg3MzY5MjQxNTc2lnbml...", "id": "clu3auej6001ds60wqhh4tzgx" } } ``` 2. Returned in this request is the `share` and `id` of that share for each respective curve! In your production build you’ll store the full object that is returned, `generateReponse`, in a secure location. For this guide, just save this value for Signing. 3. As a wallet safeguarding measure, we require the client to notify our backend once they have successfully stored their signing share (in either a keychain or DB). To do this, just make a single `PATCH` request to `/api/v3/clients/me/signing-share-pairs` with the `signingSharePairIds` you get from the generate response and a `"STORED_CLIENT"` status. ```typescript theme={null} const SECP256K1_SHARE_ID: string = response.secp256k1.id const ED25519_SHARE_ID: string = response.ed25519.id ``` ```bash theme={null} curl -X PATCH 'https://api.portalhq.io/api/v3/clients/me/signing-share-pairs' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer ' \ -d '{ "status": "STORED_CLIENT", "signingSharePairIds": ["SECP256K1_SHARE_ID", "ED25519_SHARE_ID"] }' ``` This will return a `204` on success. Congrats! 🎉 You have now created your first Portal wallet and are ready to sign a transaction! # Manage Token Delegations Source: https://docs.portalhq.io/apis/enclave-mpc/guide/delegations Learn how to approve, revoke, and manage token delegations using the Enclave MPC API. Portal's Enclave MPC API supports token delegation operations across EVM and Solana chains. This guide covers building delegation transactions via the Client API and signing them with the Enclave MPC API. ## Overview The delegation endpoints allow you to: * **Approve** other addresses to spend tokens on behalf of your wallet * **Revoke** existing delegations to remove spending permissions * **Check status** of active delegations and balances * **Transfer tokens** as a delegate from another address ## Prerequisites Before using delegation operations, ensure you have: * A properly initialized Portal client (see [Create a client](./create-a-client)) * An active wallet with tokens on the target network (see [Create a wallet](./create-a-wallet)) * Understanding of [token delegations concepts](/resources/delegations) Delegations apply to ERC-20 tokens (EVM) and SPL Tokens (Solana) only. Native assets like ETH, MON, and SOL cannot be delegated — they have no on-chain `approve` / `transferFrom` (or SPL delegate) semantics. Calls using a native asset identifier will be rejected. See [Delegations](/resources/delegations#what-are-token-delegations) for the protocol-level reason and workarounds. ## Approving Delegations Approving a delegation is a two-step process: build the unsigned transaction with the Client API, then sign and submit it with the Enclave MPC API. ### Step 1: Build the Approval Transaction Use `POST /api/v3/clients/me/chains/{chain}/assets/{token}/approvals` to build an unsigned approval transaction. For complete API documentation, see the [Client API reference](../../client/reference#approve-a-delegation). ```bash theme={null} curl --request POST \ --url 'https://api.portalhq.io/api/v3/clients/me/chains/eip155:11155111/assets/USDC/approvals' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "delegateAddress": "0xc74c4f7e330a62e0023f50b2b7b0491878fb4aa4", "amount": "0.01" }' ``` The response includes a `transactions` array with unsigned transaction objects: ```json theme={null} { "transactions": [ { "from": "0xa3e533c3df3c7cd9189c4578747c4448a295d22b", "to": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", "data": "0x095ea7b3000000000000000000000000c74c4f7e330a62e0023f50b2b7b0491878fb4aa40000000000000000000000000000000000000000000000000000000000002710" } ], "metadata": { "chainId": "eip155:11155111", "ownerAddress": "0xa3e533c3df3c7cd9189c4578747c4448a295d22b", "delegateAmount": "0.01", "delegateAddress": "0xc74c4f7e330a62e0023f50b2b7b0491878fb4aa4", "tokenSymbol": "USDC" } } ``` ```bash theme={null} curl --request POST \ --url 'https://api.portalhq.io/api/v3/clients/me/chains/solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/assets/USDC/approvals' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "delegateAddress": "7KAJGeyLUdLXv9p7rFYpYUnX5Jac6CYMTuJSQr7FZzz1", "amount": "0.01" }' ``` The response includes an `encodedTransactions` array with base64-encoded transactions: ```json theme={null} { "encodedTransactions": [ "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAIEC4lO3sml7OPCb2B7l24qng1cRm9J9aGqX+bt2ZRQnG/Is0RJBcOn7ZMos22MlvccAz+6hfOK3RzmBaeYNAJvGl3NFtEmNi3ankpovc/5QrK4ZTPvO7LJsTMyzacyiq5OBt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKmvKeQuDyOC4uvNwTHJYrcdHOlP9zX9unzvI5P1JRmV3wEDAwECAAkEECcAAAAAAAA=" ], "metadata": { "chainId": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", "delegateAddress": "7KAJGeyLUdLXv9p7rFYpYUnX5Jac6CYMTuJSQr7FZzz1", "delegateAmount": "0.01", "delegateAmountRaw": "10000", "lastValidBlockHeight": "434058739", "ownerAddress": "n2vQ3c8emmQsjPRvLFaBVRv6UeTFXyDYa5MBRoY7QCN", "serializedTransactionBase64Encoded": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAIEC4lO3sml7OPCb2B7l24qng1cRm9J9aGqX+bt2ZRQnG/Is0RJBcOn7ZMos22MlvccAz+6hfOK3RzmBaeYNAJvGl3NFtEmNi3ankpovc/5QrK4ZTPvO7LJsTMyzacyiq5OBt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKmvKeQuDyOC4uvNwTHJYrcdHOlP9zX9unzvI5P1JRmV3wEDAwECAAkEECcAAAAAAAA=", "serializedTransactionBase58Encoded": "3T4DHUNXqSgNrtRMsDwQr7oG7756JXW4uNJ7x7RKJLTcSJRe6PqyR5fQffnXh4KTHs4MyRrDhR1K3zCeRrWsJBeoXxrwPvXaNz6ZYFY6Ci6qJYrcWiCoL8hGQJtwAAWE4EBz8mNL78DyJCbUp9rz3Wfyu2R8eY9VYzAQC9vhFqTgXHNMiNF31QPDh7K4nWSyvAuy5nbGUAGvKiYvNf4MDQoBnNPis6SnTzr2wPe8XKMePetZ29rqXbTvRWacnjA8w2DYbFyHiR5KT2ypN6UC6T6UkcGGAH467yCzTm2Q2B1MHAjAaMC4GY6DhUdZ4MPj2v8vf7RzQQST2f", "tokenAccount": "EWT5sTcrawKJ73w5YXy9n2WD4uCo2UCEEkUi1potmP7P", "tokenAddress": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", "tokenSymbol": "USDC", "tokenDecimals": 6, "unsignedTransactionMessage": { "signatures": null, "message": { "accountKeys": [ "n2vQ3c8emmQsjPRvLFaBVRv6UeTFXyDYa5MBRoY7QCN", "EWT5sTcrawKJ73w5YXy9n2WD4uCo2UCEEkUi1potmP7P", "7KAJGeyLUdLXv9p7rFYpYUnX5Jac6CYMTuJSQr7FZzz1", "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" ], "header": { "numRequiredSignatures": 1, "numReadonlySignedAccounts": 0, "numReadonlyUnsignedAccounts": 2 }, "instructions": [ { "programIdIndex": 3, "accounts": [ 1, 2, 0 ], "data": "3zynHDTKC8Du" } ], "recentBlockhash": "CnmPyBUhGgajDq8CbBAtBsfJWzeA5Zh9djmwRNgntMxr" } } } } ``` ### Step 2: Sign and Submit the Transaction Use the Enclave MPC API to sign and submit the transaction returned in Step 1. For complete API documentation, see the [Enclave MPC API reference](../../enclave-mpc/reference#sign-a-transaction-or-message-by-chain). ```bash theme={null} curl --request POST \ --url https://mpc-client.portalhq.io/v1/sign \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "share": "SECP256K1.share", "method": "eth_sendTransaction", "params": { "from": "0xc74c4f7e330a62e0023f50b2b7b0491878fb4aa4", "to": "0xc74c4f7e330a62e0023f50b2b7b0491878fb4aa4", "data": "0x23b872dd000000000000000000000000a3e533c3df3c7cd9189c4578747c4448a295d22b000000000000000000000000c74c4f7e330a62e0023f50b2b7b0491878fb4aa40000000000000000000000000000000000000000000000000000000000002710", "value": "0x0" }, "rpcUrl": "https://api.portalhq.io/rpc/v1/eip155/11155111", "chainId": "eip155:11155111" }' ``` ```bash theme={null} curl --request POST \ --url https://mpc-client.portalhq.io/v1/sign \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "share": "ED25519.share", "method": "sol_signAndSendTransaction", "params": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAUHw3aL9Sv...", "rpcUrl": "https://api.portalhq.io/rpc/v1/solana/EtWTRABZaYq6iMfeYKouRu166VU2xqa1", "chainId": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" }' ``` *** ## Checking Delegation Status Use `GET /api/v3/clients/me/chains/:chain/assets/:token/delegations` to check active delegations and token balances. This is a read-only call that does not require signing. The `delegateAddress` query parameter behaves per chain: on EVM (eip155) chains it is required and the response returns the allowance for that spender. On Solana it is optional. Omit it to get the account's current delegate (SPL token accounts have at most one), or supply it to check a specific address, and the `delegations` array is empty if that address is not the current delegate. For complete API documentation, see the [Client API reference](../../client/api-reference-link). ```bash theme={null} curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/chains/eip155:11155111/assets/USDC/delegations?delegateAddress=0xc74c4f7e330a62e0023f50b2b7b0491878fb4aa4' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' ``` The response includes the token balance and a list of active delegations: ```json theme={null} { "balance": "0.0", "balanceRaw": "0", "chainId": "eip155:11155111", "delegations": [ { "address": "0xc74c4f7e330a62e0023f50b2b7b0491878fb4aa4", "delegateAmount": "0.01", "delegateAmountRaw": "10000" } ], "token": "USDC", "tokenAddress": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238" } ``` ```bash theme={null} curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/chains/solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/assets/USDC/delegations?delegateAddress=7KAJGeyLUdLXv9p7rFYpYUnX5Jac6CYMTuJSQr7FZzz1' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' ``` The response includes the token balance and a list of active delegations: ```json theme={null} { "balance": "0.000000", "balanceRaw": "0", "chainId": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", "delegations": [ { "address": "7KAJGeyLUdLXv9p7rFYpYUnX5Jac6CYMTuJSQr7FZzz1", "delegateAmount": "0.010000", "delegateAmountRaw": "10000" } ], "token": "USDC", "tokenAddress": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", "tokenAccount": "2xMj47SeAfYnn6XwAktnJjabEfT4L4KEgddZUZ8FHvxc" } ``` To list the account's current delegate without checking a specific address, omit the query parameter: ```bash theme={null} curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/chains/solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/assets/USDC/delegations' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' ``` *** ## Revoking Delegations Revoking follows the same two-step pattern: build the revocation transaction, then sign and submit it. ### Step 1: Build the Revocation Transaction Use `POST /api/v3/clients/me/chains/:chain/assets/:token/revocations` to build an unsigned revocation transaction. For complete API documentation, see the [Client API reference](../../client/reference#revoke-a-delegation). ```bash theme={null} curl --request POST \ --url 'https://api.portalhq.io/api/v3/clients/me/chains/eip155:11155111/assets/USDC/revocations' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "delegateAddress": "0xc74c4f7e330a62e0023f50b2b7b0491878fb4aa4" }' ``` ```bash theme={null} curl --request POST \ --url 'https://api.portalhq.io/api/v3/clients/me/chains/solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/assets/USDC/revocations' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "delegateAddress": "7KAJGeyLUdLXv9p7rFYpYUnX5Jac6CYMTuJSQr7FZzz1" }' ``` The response structure matches the approval response, returning `transactions` (EVM) or `encodedTransactions` (Solana). ### Step 2: Sign and Submit the Transaction Sign and submit the revocation transaction using the same Enclave MPC signing flow shown in the [approval step](#step-2-sign-and-submit-the-transaction). For complete API documentation, see the [Enclave MPC API reference](../../enclave-mpc/reference#sign-a-transaction-or-message-by-chain). Always revoke unused delegations after completing operations to minimize security risks. *** ## Transferring as a Delegate Once an owner has approved your address as a delegate, you can transfer tokens from their wallet to a destination address. ### Step 1: Build the Transfer Transaction Use `POST /api/v3/clients/me/chains/:chain/assets/:token/delegations/transfers` to build an unsigned transfer-as-delegate transaction. For complete API documentation, see the [Client API reference](../../client/reference#transfer-as-a-delegate). ```bash theme={null} curl --request POST \ --url 'https://api.portalhq.io/api/v3/clients/me/chains/eip155:11155111/assets/USDC/delegations/transfers' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "fromAddress": "0xa3e533c3df3c7cd9189c4578747c4448a295d22b", "toAddress": "0xc74c4f7e330a62e0023f50b2b7b0491878fb4aa4", "amount": "0.01" }' ``` The response includes the unsigned transaction(s) along with metadata about the transfer: ```json theme={null} { "transactions": [ { "from": "0xc74c4f7e330a62e0023f50b2b7b0491878fb4aa4", "to": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", "data": "0x23b872dd000000000000000000000000a3e533c3df3c7cd9189c4578747c4448a295d22b000000000000000000000000c74c4f7e330a62e0023f50b2b7b0491878fb4aa40000000000000000000000000000000000000000000000000000000000002710" } ], "metadata": { "amount": "0.01", "amountRaw": "10000", "chainId": "eip155:11155111", "delegateAddress": "0xc74c4f7e330a62e0023f50b2b7b0491878fb4aa4", "ownerAddress": "0xa3e533c3df3c7cd9189c4578747c4448a295d22b", "recipientAddress": "0xc74c4f7e330a62e0023f50b2b7b0491878fb4aa4", "tokenAddress": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", "tokenSymbol": "USDC", "tokenDecimals": 6 } } ``` ```bash theme={null} curl --request POST \ --url 'https://api.portalhq.io/api/v3/clients/me/chains/solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/assets/USDC/delegations/transfers' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "fromAddress": "n2vQ3c8emmQsjPRvLFaBVRv6UeTFXyDYa5MBRoY7QCN", "toAddress": "7KAJGeyLUdLXv9p7rFYpYUnX5Jac6CYMTuJSQr7FZzz1", "amount": "0.01" }' ``` The response includes an `encodedTransactions` array with base64-encoded transactions: ```json theme={null} { "encodedTransactions": [ "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAQHXc0W0SY2LdqeSmi9z/lCsrhlM+87ssmxMzLNpzKKrk7Is0RJBcOn7ZMos22MlvccAz+6hfOK3RzmBaeYNAJvGu3dgwf/7bDZTAAvIbI54fqs7ezz9zThi0kYkIaEhbfGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA7RCyzkSFX8TqTPQE0KC0DK1/+zQGi2/G3eQYI3wAup4yXJY9OJInxuz0QKRSODYMLWhOZ2v8QhASOe9jb6fhZBt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKmVObR4y3ElZQoQnGi0XXe/r+pD1Xchw2XGx9IfjDnBNQIFBgACAAQDBgAGAwECAAkDECcAAAAAAAA=" ], "metadata": { "amount": "0.01", "amountRaw": "10000", "chainId": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", "delegateAddress": "7KAJGeyLUdLXv9p7rFYpYUnX5Jac6CYMTuJSQr7FZzz1", "ownerAddress": "n2vQ3c8emmQsjPRvLFaBVRv6UeTFXyDYa5MBRoY7QCN", "recipientAddress": "7KAJGeyLUdLXv9p7rFYpYUnX5Jac6CYMTuJSQr7FZzz1", "tokenAddress": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", "tokenSymbol": "USDC", "tokenDecimals": 6, "needsRecipientTokenAccount": true, "lastValidBlockHeight": "434059047", "serializedTransactionBase64Encoded": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAQHXc0W0SY2LdqeSmi9z/lCsrhlM+87ssmxMzLNpzKKrk7Is0RJBcOn7ZMos22MlvccAz+6hfOK3RzmBaeYNAJvGu3dgwf/7bDZTAAvIbI54fqs7ezz9zThi0kYkIaEhbfGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA7RCyzkSFX8TqTPQE0KC0DK1/+zQGi2/G3eQYI3wAup4yXJY9OJInxuz0QKRSODYMLWhOZ2v8QhASOe9jb6fhZBt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKmVObR4y3ElZQoQnGi0XXe/r+pD1Xchw2XGx9IfjDnBNQIFBgACAAQDBgAGAwECAAkDECcAAAAAAAA=", "serializedTransactionBase58Encoded": "D8oZQdSemcrQBAEScyqQxo9zES3BszEVMppfay1AJAEfQrNuxXNAKCHtbhqGNQcvUKzNVTC8osiVbqtbiimLn8cnfqDFqtnEGsbsbvWXstVFxnHu9ZVtR8iSQqszkbZr4S2Lsisqehfzux3spSJogiYnsq4TwAfJ6Nc7XKLm3i8Qk9R7UP28CKtdoGQ7QDmaL9LiBDcWnhM19ZtNrRWPbySzDTke3hcnCQc9eRj89TVTiMJJEbi4MJ1eGDoXtusLdmA6pjxWS95Pyi2rCcQ31tpUMvXWa8iPXn1fFJKYmFrhaEvQXaF32tzQ4nqa7pdCUssrg4Rf6BemEg2P99MTFBasSxe6F5qV9Qwxt63R5JuoRmqy7zA5v1huM8UDcc5SQLcYFrr7z5FJphuyXu6k7MGQ8VzNphihSX2pa9FSLff6iRS8QZLS53UNZozNqVQ5MF5nJXnUtrxp97YM4esFRcV8vMZAf", "unsignedTransactionMessage": { "signatures": null, "message": { "accountKeys": [ "7KAJGeyLUdLXv9p7rFYpYUnX5Jac6CYMTuJSQr7FZzz1", "EWT5sTcrawKJ73w5YXy9n2WD4uCo2UCEEkUi1potmP7P", "H1XXmpUULrBxdNPELq3oAH6kQFaak8uCCVu3kxyQg7cy", "11111111111111111111111111111111", "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" ], "header": { "numRequiredSignatures": 1, "numReadonlySignedAccounts": 0, "numReadonlyUnsignedAccounts": 4 }, "instructions": [ { "programIdIndex": 5, "accounts": [ 0, 2, 0, 4, 3, 6 ], "data": "" }, { "programIdIndex": 6, "accounts": [ 1, 2, 0 ], "data": "3GAG5eogvTjV" } ], "recentBlockhash": "B3WnL5RxXxi4TXE5cSQJHkErsqjr1QQRJSiAycidYWWQ" } } } } ``` ### Step 2: Sign and Submit the Transaction Sign and submit the transfer transaction using the same Enclave MPC signing flow shown in the [approval step](#step-2-sign-and-submit-the-transaction). For complete API documentation, see the [Enclave MPC API reference](../../enclave-mpc/reference#sign-a-transaction-or-message-by-chain). **Delegation Roles**: `fromAddress` is the token owner who approved the delegation. Your wallet (the delegate) signs the transaction to transfer tokens from the owner to the `toAddress` recipient. *** ## Supported Networks Delegations work on all Portal-supported EVM and Solana chains: * **EVM**: Ethereum, Polygon, Base, Arbitrum, Optimism, Monad, and all other EVM-compatible chains * **Solana**: Solana Mainnet and Devnet For a complete list, see [Blockchain Support](/resources/blockchain-support). *** ## Next Steps * Learn about [signing Ethereum transactions](./sign-ethereum-transactions) * Learn about [signing Solana transactions](./sign-solana-transactions) * Review [delegation concepts](/resources/delegations) * Check out the [Client API delegation endpoints](../../client/reference#get-delegation-status-for-an-address) # Eject a wallet Source: https://docs.portalhq.io/apis/enclave-mpc/guide/eject-a-wallet The eject feature allows a user to construct a private key that can be imported into another wallet manager, such as MetaMask. # Eject a wallet Eject combines a user's two matching MPC backup shares into a raw private key, letting them move a wallet off MPC and, on EVM chains, import it into a wallet manager like MetaMask. **Warning:** Providing the custodian backup share to the client device puts both MPC shares on a single device, removing the multi-party security benefits of MPC. This operation should only be done for users who want to move off of MPC and into a single private key. ## Ejecting a Wallet To eject the private keys for your wallets, the two matching backup shares need to be combined. This is done by fetching the backup shares and then combining them with the [@portal-hq/eject-js](https://www.npmjs.com/package/@portal-hq/eject-js) NPM package. `@portal-hq/eject-js` combines two **plaintext** MPC shares into a raw private key. It does **not** fetch or decrypt shares for you - your code is responsible for retrieving each share and, when using Portal-Managed Backups, decrypting the client backup share before passing it in. **Share encodings differ.** The client backup share you get from `/v1/backup` is **base64-encoded JSON**, so you must base64-decode it before passing it to eject-js. The custodian backup share is a **JSON string** (the API returns it JSON-stringified); pass it to eject-js as-is. Only the client share is base64-encoded and needs decoding. Both samples below use this helper for the client share: ```typescript theme={null} // Decodes the base64 client backup share (from /v1/backup) into the JSON eject-js expects. const decodeClientBackupShare = (base64Share) => Buffer.from(base64Share, 'base64').toString('utf8') ``` Store the client share **encrypted** at rest, as [Back up a wallet](./backup) describes. Once you decrypt it, its base64 form is what `/v1/recover` consumes directly; only eject-js needs it base64-decoded, so decode at eject time. ### Prerequisites * Install the eject package: `npm install @portal-hq/eject-js`. * A [Custodian API key](/resources/authentication-and-api-keys). The endpoints below are Custodian API endpoints and must be called from your backend - never expose a Custodian API key in client code. * For the Portal-Managed Backups flow, your environment must have **Portal-Managed Backups enabled**. If it is not, the `ejectable-backup-shares` endpoint returns a `400`. ### With Portal-Managed Backups With Portal-Managed Backups, both backup shares are stored (encrypted) with Portal. To eject a wallet's private key, fetch both shares from the Custodian API and combine them: * **Custodian Backup Share** - returned already decrypted by Portal, ready to pass to eject. * **Client Backup Share** - returned as `encryptedClientBackupShare`, which is the ciphertext your application produced at backup time. You must decrypt it before combining, reversing the encryption your application applied when it [backed up the wallet](./backup). The general steps are: 1. Get the Portal client's details and find their `SECP256K1` and `ED25519` wallets. 2. Fetch each wallet's ejectable backup shares (`encryptedClientBackupShare` + `custodianBackupShare`). 3. Decrypt each `encryptedClientBackupShare` (see the note below). 4. Combine each pair of shares to recover the private keys. ```typescript theme={null} import axios from 'axios' import { recoverSecp256k1Key, recoverEd25519Key } from '@portal-hq/eject-js' const clientId = 'example-client-id' // Authenticate every request with your Custodian API key. const api = axios.create({ baseURL: 'https://api.portalhq.io/api/v3', headers: { Authorization: `Bearer ${process.env.PORTAL_CUSTODIAN_API_KEY}` }, }) // 1. Get the Portal client and find their wallets. const { data: clientDetails } = await api.get(`/custodians/me/clients/${clientId}`) const secp256k1Wallet = clientDetails.wallets.find((wallet) => wallet.curve === 'SECP256K1') const ed25519Wallet = clientDetails.wallets.find((wallet) => wallet.curve === 'ED25519') // 2. Fetch each wallet's ejectable backup shares. const { data: secp256k1Shares } = await api.get(`/custodians/me/clients/${clientId}/wallets/${secp256k1Wallet.id}/ejectable-backup-shares`) const { data: ed25519Shares } = await api.get(`/custodians/me/clients/${clientId}/wallets/${ed25519Wallet.id}/ejectable-backup-shares`) // 3. Decrypt the client backup shares, then base64-decode them. `decryptClientBackupShare` // is your own implementation that reverses the encryption applied at backup time. const secp256k1ClientShare = decodeClientBackupShare(await decryptClientBackupShare(secp256k1Shares.encryptedClientBackupShare)) const ed25519ClientShare = decodeClientBackupShare(await decryptClientBackupShare(ed25519Shares.encryptedClientBackupShare)) // 4. Recover the private keys. custodianBackupShare is a JSON string from the API - pass it as-is (eject-js parses it). const secp256k1PrivateKey = await recoverSecp256k1Key(secp256k1ClientShare, secp256k1Shares.custodianBackupShare) // hex (EVM) const ed25519PrivateKey = await recoverEd25519Key(ed25519ClientShare, ed25519Shares.custodianBackupShare) // base58 (Solana) ``` `decryptClientBackupShare` is a placeholder for your own decryption logic. With the Enclave MPC API, **your application encrypts the client backup share itself at backup time** - Portal has no built-in backup method here and only stores the ciphertext you give it. Eject must therefore reverse that same encryption with the same key. If you followed the recommendation in [Back up a wallet](./backup) and used a KMS with envelope encryption, decrypt using that same KMS key. The `custodianBackupShare` is returned already decrypted and needs no such step. If you stored the whole `{ share, id }` object returned by `/v1/backup` (rather than just the `share` string), take its `.share` field before calling `decodeClientBackupShare`. **`SECP256K1`** is the curve used by Ethereum and **`ED25519`** is the curve used by Solana. You can learn more about signing algorithms and curves [here](http://ethanfast.com/top-crypto.html). ### With Self-Managed Backups With Self-Managed Backups you store both backup shares yourself during backup. The eject transforms are the same as above; only the fetch location differs - you read both shares from your own backend instead of from Portal. * **Client Backup Shares** - the client backup shares you stored. Decrypt them (reversing whatever encryption you applied at backup) and base64-decode them, exactly as in the Portal-Managed flow. * **Custodian Backup Shares** - the custodian backup shares delivered to your backup webhook as a JSON string. Pass them to eject as-is. ```typescript theme={null} import { recoverSecp256k1Key, recoverEd25519Key } from '@portal-hq/eject-js' const decodeClientBackupShare = (base64Share) => Buffer.from(base64Share, 'base64').toString('utf8') // Fetch + decrypt + decode the client backup shares you stored during backup. const secp256k1ClientShare = decodeClientBackupShare(await decryptClientBackupShare(await fetchSecp256k1ClientBackupShare())) const ed25519ClientShare = decodeClientBackupShare(await decryptClientBackupShare(await fetchEd25519ClientBackupShare())) // Fetch the custodian backup shares you stored during backup (a JSON string; pass as-is). const secp256k1CustodianShare = await fetchSecp256k1CustodianBackupShare() const ed25519CustodianShare = await fetchEd25519CustodianBackupShare() // Recover a secp256k1 private key (EVM). const secp256k1PrivateKey = await recoverSecp256k1Key(secp256k1ClientShare, secp256k1CustodianShare) // Recover an ed25519 private key (Solana). const ed25519PrivateKey = await recoverEd25519Key(ed25519ClientShare, ed25519CustodianShare) ``` ## Using the ejected keys The recovered `SECP256K1` key (hex) can be imported into most third-party wallets - for example, MetaMask's private-key import. The `ED25519` key (base58) is a valid Solana key, but it is **not** directly importable into most third-party Solana wallets: a nuance of MPC distributed key generation for `ED25519` prevents it. Portal's open-source [scalarwallet.org](https://scalarwallet.org) (self-host or hosted) lets you use it. See [Eject](/resources/eject) for more detail. # Getting Started Source: https://docs.portalhq.io/apis/enclave-mpc/guide/getting-started This guide will walk you through creating a wallet and signing transactions using the Portal Enclave MPC API on various chains. ### 1. Create a Test User We'll visit the [Portal Admin Dashboard](https://app.portalhq.io) to create a test client 🧪 ### 2. Create a Wallet Make 1 request to create a wallet and 1 request to confirm you stored it securely 🔐 ### 3. Sign and Submit Transactions Sign and submit Ethereum and Solana transactions with the wallet 📝 Let's get started! # Bridge & Swap with Li.Fi Source: https://docs.portalhq.io/apis/enclave-mpc/guide/lifi Learn how to bridge and swap tokens across multiple chains using the Enclave MPC API with Li.Fi integration. Portal's Enclave MPC API provides comprehensive cross-chain bridging and swapping capabilities through the Li.Fi integration. This guide covers getting quotes, finding routes, executing swaps and bridges, and tracking transaction status. ## Overview The Li.Fi functionality allows you to: * **Get quotes** for bridging or swapping tokens across chains * **Find routes** to discover the best paths for your cross-chain transfers * **Execute swaps and bridges** by signing and submitting transactions * **Track transaction status** for cross-chain transfers * **Get available tokens** supported by Li.Fi across chains ## Prerequisites Before using Li.Fi operations, ensure you have: * A properly initialized Portal client (see [Create a client](./create-a-client)) * An active wallet with the required token(s) on the source network (see [Create a wallet](./create-a-wallet)) * Li.Fi integration enabled in your Portal Dashboard (see [Li.Fi Integration](../../../integrations/Trading/lifi)) ## Getting a Quote Use the `POST /api/v3/clients/me/integrations/lifi/quote` endpoint to get a quote for bridging or swapping tokens across chains. For complete API documentation, see the [Client API reference](../../client/reference#get-li-fi-quote). ```bash theme={null} curl --request POST \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/lifi/quote' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "fromChain": "eip155:1", "fromAmount": "1000000000000000000", "fromToken": "ETH", "toChain": "eip155:137", "toToken": "USDC", "fromAddress": "0x3fa7bc5cf02f07ed174d124a72fcf8d5276df546" }' ``` The response includes a `transactionRequest` object with the transaction details (`from`, `to`, `value`, `data`) that you'll need to sign and submit. ## Finding Routes Use the `POST /api/v3/clients/me/integrations/lifi/routes` endpoint to discover available routes for your cross-chain transfer. For complete API documentation, see the [Client API reference](../../client/reference#get-li-fi-routes). ```bash theme={null} curl --request POST \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/lifi/routes' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "fromChainId": "eip155:1", "fromAmount": "1000000000000000000", "fromTokenAddress": "0x0000000000000000000000000000000000000000", "toChainId": "eip155:137", "toTokenAddress": "USDC" }' ``` The response includes an array of routes with estimates, fees, and gas costs. Routes may be tagged as `RECOMMENDED`, `CHEAPEST`, or `FASTEST`. ## Collecting Integrator Fees Pass `integrator` and `fee` to collect a fee on each swap or bridge. `integrator` must be the exact integrator ID that you registered in the [LI.FI Portal](https://portal.li.fi/). `fee` is a decimal, not a percentage, so `0.005` means 0.5%. Valid values are `0 <= fee < 1`. LI.FI adds its own platform fee on top of your `fee`, so the total fee in the quote is higher than the value you pass. On the quote endpoint, these are top-level fields: ```bash theme={null} curl --request POST \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/lifi/quote' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "fromChain": "eip155:1", "fromAmount": "1000000000000000000", "fromToken": "ETH", "toChain": "eip155:137", "toToken": "USDC", "fromAddress": "0x3fa7bc5cf02f07ed174d124a72fcf8d5276df546", "integrator": "YOUR_INTEGRATOR_ID", "fee": 0.005 }' ``` On the routes endpoint, they live inside `options`: ```bash theme={null} curl --request POST \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/lifi/routes' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "fromChainId": "eip155:1", "fromAmount": "1000000000000000000", "fromTokenAddress": "0x0000000000000000000000000000000000000000", "toChainId": "eip155:137", "toTokenAddress": "USDC", "options": { "integrator": "YOUR_INTEGRATOR_ID", "fee": 0.005 } }' ``` The quote response shows the fee breakdown in `estimate.feeCosts[]`. Your share appears as `integratorFee` in the `feeSplit` object: ```json theme={null} { "estimate": { "feeCosts": [ { "name": "LIFI Fixed Fee", "amount": "750", "percentage": "0.0075", "feeSplit": { "lifiFee": "250", "integratorFee": "500", "recipients": [ { "name": "lifi", "type": "FIXED", "fee": "250" }, { "name": "YOUR_INTEGRATOR_ID", "type": "FIXED", "fee": "500" } ] } } ] } } ``` Set up your integrator ID and fee receiver wallets in the [LI.FI Portal](https://portal.li.fi/) before you pass `fee`. LI.FI pays collected fees to those wallets, and you withdraw them from the LI.FI Portal. If you omit `integrator`, Portal sends its own default value and your fee configuration does not apply. The `referrer` parameter is an optional tracking string only. It does not control where fees are paid. New fee wallet configuration can take up to roughly 30 minutes to activate. Until then, quotes that pass `fee` fail with LI.FI error 1011 (`not configured for collecting fees on chain`). ## Getting Route Step Details Use the `POST /api/v3/clients/me/integrations/lifi/route-step-details` endpoint to get detailed transaction information for a specific route step, including an unsigned transaction that you can then sign and submit to an RPC provider (the `transactionRequest` field). For complete API documentation, see the [Client API reference](../../client/reference#get-li-fi-route-step-details). ```bash theme={null} curl --request POST \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/lifi/route-step-details' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "id": "a8dc011a-f52d-4492-9e99-21de64b5453a", "type": "lifi", "tool": "1inch", "toolDetails": { "key": "1inch", "logoURI": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/exchanges/oneinch.svg", "name": "1inch" }, "action": { "fromChainId": "eip155:100", "toChainId": "eip155:100", "fromToken": { "address": "0x0000000000000000000000000000000000000000", "symbol": "xDai", "decimals": 18, "chainId": "eip155:100", "name": "xDai", "coinKey": "xDai", "priceUSD": "1", "logoURI": "https://static.debank.com/image/xdai_token/logo_url/xdai/1207e67652b691ef3bfe04f89f4b5362.png" }, "toToken": { "name": "Minerva Wallet SuperToken", "symbol": "MIVA", "coinKey": "MIVA", "decimals": 18, "chainId": "eip155:100", "priceUSD": "1", "logoURI": "https://minerva.digital/i/MIVA-Token_200x200.png", "address": "0x63e62989d9eb2d37dfdb1f93a22f063635b07d51" }, "fromAmount": "1000000000000000000", "slippage": 0.003, "fromAddress": "0x552008c0f6870c2f77e5cC1d2eb9bdff03e30Ea0", "toAddress": "0x552008c0f6870c2f77e5cC1d2eb9bdff03e30Ea0" }, "estimate": { "fromAmount": "1000000000000000000", "toAmount": "21922914496086353975", "toAmountMin": "21265227061203763356", "tool": "1inch", "executionDuration": 30, "approvalAddress": "0x1111111254fb6c44bac0bed2854e76f90643097d", "feeCosts": [], "gasCosts": [ { "type": "SEND", "price": "1", "estimate": "252364", "limit": "315455", "amount": "252364", "amountUSD": "0.00", "token": { "address": "0x0000000000000000000000000000000000000000", "symbol": "xDai", "decimals": 18, "chainId": "eip155:100", "name": "xDai", "coinKey": "xDai", "priceUSD": "1", "logoURI": "https://static.debank.com/image/xdai_token/logo_url/xdai/1207e67652b691ef3bfe04f89f4b5362.png" } } ] }, "integrator": "fee-demo", "includedSteps": [ { "id": "a8dc011a-f52d-4492-9e99-21de64b5453a", "type": "swap", "tool": "1inch", "toolDetails": { "key": "1inch", "logoURI": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/exchanges/oneinch.svg", "name": "1inch" }, "action": { "fromChainId": "eip155:100", "toChainId": "eip155:100", "fromToken": { "address": "0x0000000000000000000000000000000000000000", "symbol": "xDai", "decimals": 18, "chainId": "eip155:100", "name": "xDai", "coinKey": "xDai", "priceUSD": "1", "logoURI": "https://static.debank.com/image/xdai_token/logo_url/xdai/1207e67652b691ef3bfe04f89f4b5362.png" }, "toToken": { "name": "Minerva Wallet SuperToken", "symbol": "MIVA", "coinKey": "MIVA", "decimals": 18, "chainId": "eip155:100", "priceUSD": "1", "logoURI": "https://minerva.digital/i/MIVA-Token_200x200.png", "address": "0x63e62989d9eb2d37dfdb1f93a22f063635b07d51" }, "fromAmount": "1000000000000000000", "slippage": 0.003, "fromAddress": "0x552008c0f6870c2f77e5cC1d2eb9bdff03e30Ea0", "toAddress": "0x552008c0f6870c2f77e5cC1d2eb9bdff03e30Ea0" }, "estimate": { "fromAmount": "1000000000000000000", "toAmount": "21922914496086353975", "toAmountMin": "21265227061203763356", "tool": "1inch", "executionDuration": 30, "approvalAddress": "0x1111111254fb6c44bac0bed2854e76f90643097d", "feeCosts": [], "gasCosts": [ { "type": "SEND", "price": "1", "estimate": "252364", "limit": "315455", "amount": "252364", "amountUSD": "0.00", "token": { "address": "0x0000000000000000000000000000000000000000", "symbol": "xDai", "decimals": 18, "chainId": "eip155:100", "name": "xDai", "coinKey": "xDai", "priceUSD": "1", "logoURI": "https://static.debank.com/image/xdai_token/logo_url/xdai/1207e67652b691ef3bfe04f89f4b5362.png" } } ] } } ] }' ``` The response includes a `transactionRequest` object with the transaction details you'll need to sign and submit. ## Getting Available Tokens Use the `GET /api/v3/clients/me/integrations/lifi/tokens` endpoint to retrieve all tokens supported by Li.Fi. You can filter results by chain, chain type, minimum price, or tag. For complete API documentation, see the [Client API reference](../../client/reference#get-li-fi-tokens). ```bash theme={null} curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/lifi/tokens?chains=ETH,ARB&minPriceUSD=0.01' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' ``` ## Executing a Swap or Bridge After getting a quote or route step details, you'll receive a `transactionRequest` object. Extract the `from`, `to`, `value`, and `data` fields to sign and submit the transaction. ### Step 0: Approve the ERC-20 fromToken (if needed) If your `fromToken` is an ERC-20, the Li.Fi router cannot move it on your behalf until you grant an on-chain allowance. Skip this step when the `fromToken` is the chain's native asset (its `address` is `0x0000000000000000000000000000000000000000`). Extract the following values from the quote response: * **chainId**: the `fromChain` you sent into the quote request. * **approvalAddress**: `data.rawResponse.estimate.approvalAddress`. * **fromTokenAddress**: `data.rawResponse.action.fromToken.address`. * **amount**: the `fromAmount` you sent into the quote request, divided by `10^data.rawResponse.action.fromToken.decimals` (use a BigInt util for this). Pass it as a string in the token's primary denomination (e.g. `"0.01"` for 0.01 USDC). Then call the approvals endpoint to build an unsigned approval transaction: ```bash theme={null} curl --request POST \ --url 'https://api.portalhq.io/api/v3/clients/me/chains//assets//approvals' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "delegateAddress": "[approvalAddress]", "amount": "[amount]" }' ``` The response returns a `transactions` array. Sign each entry with the `eth_sendTransaction` flow shown in [Step 1](#step-1-sign-the-transaction) before continuing on to sign the swap transaction itself. For complete endpoint documentation, see the [Client API reference](../../client/reference#approve-a-delegation). ### Step 1: Sign the Transaction Use the Enclave MPC API to sign the transaction: ```bash theme={null} curl --request POST \ --url https://mpc-client.portalhq.io/v1/sign \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "share": "[share]", "method": "eth_sendTransaction", "params": { "from": "0x3fa7bc5cf02f07ed174d124a72fcf8d5276df546", "to": "0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE", "value": "0xde0b6b3a7640000", "data": "0x25d374e800000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000220..." }, "rpcUrl": "https://api.portalhq.io/rpc/v1/eip155/1", "chainId": "eip155:1" }' ``` The `transactionRequest` from Li.Fi may include `gasPrice` and `gasLimit` fields. You can remove these if you want Portal to estimate the gas for you, or include them if you want to use Li.Fi's estimates. ### Step 2: Track Transaction Status After signing and broadcasting the transaction, use the `GET /api/v3/clients/me/integrations/lifi/status` endpoint to track the status of your cross-chain transfer. For complete API documentation, see the [Client API reference](../../client/reference#get-li-fi-status). ```bash theme={null} curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/lifi/status?fromChain=eip155:1&txHash=0x...' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' ``` ## Example Flow Here's a complete example of executing a cross-chain bridge: ```bash theme={null} # 1. Get a quote for bridging ETH from Ethereum to Polygon QUOTE_RESPONSE=$(curl --request POST \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/lifi/quote' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "fromChain": "eip155:1", "fromAmount": "1000000000000000000", "fromToken": "ETH", "toChain": "eip155:137", "toToken": "USDC", "fromAddress": "0x3fa7bc5cf02f07ed174d124a72fcf8d5276df546" }') # Extract transactionRequest from response (parse JSON) # transactionRequest contains: from, to, value, data, chainId # 2. If fromToken is an ERC-20 (its address is not 0x0000...0000), # build an approval transaction and sign it before signing the swap. # Skip this block for native-asset swaps. APPROVAL_RESPONSE=$(curl --request POST \ --url 'https://api.portalhq.io/api/v3/clients/me/chains/[chainId]/assets/[fromTokenAddress]/approvals' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "delegateAddress": "[approvalAddress]", "amount": "[amount]" }') # Sign each transaction returned in APPROVAL_RESPONSE.transactions[] # using the same eth_sendTransaction flow as below. # 3. Sign and submit the transaction curl --request POST \ --url https://mpc-client.portalhq.io/v1/sign \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "share": "[share]", "method": "eth_sendTransaction", "params": { "from": "[fromAddress]", "to": "[toAddress]", "value": "[value]", "data": "[data]" }, "rpcUrl": "https://api.portalhq.io/rpc/v1/eip155/1", "chainId": "eip155:1" }' # 4. Extract transaction hash from response and track status curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/lifi/status?fromChain=eip155:1&txHash=0xTransactionHash' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' ``` ## Best Practices 1. **Compare quotes/routes** before signing and submitting the transaction(s) to find the best option for your use case 2. **Handle network errors gracefully** and provide user feedback 3. **Monitor transaction status** for cross-chain transfers, as they may take longer than single-chain transactions 4. **Validate user balances** before initiating swaps or bridges ## Supported Networks Portal's Li.Fi integration supports the following mainnet networks: * Monad (`eip155:143`) * Ethereum (`eip155:1`) * Optimism (`eip155:10`) * BSC (`eip155:56`) * Gnosis (`eip155:100`) * Unichain (`eip155:130`) * Polygon (`eip155:137`) * Sonic (`eip155:146`) * Mantle (`eip155:5000`) * Base (`eip155:8453`) * Arbitrum (`eip155:42161`) * Celo (`eip155:42220`) * Avalanche (`eip155:43114`) * Linea (`eip155:59144`) * Berachain (`eip155:80094`) * Katana (`eip155:747474`) * Solana (`solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`) * Bitcoin (`bip122:000000000019d6689c085ae165831e93-p2wpkh`) For the complete list of networks Li.Fi supports across its ecosystem, refer to the [Li.Fi documentation](https://docs.li.fi). If you need a chain that isn't listed above, contact Portal support. **Testnets are not supported.** ## Next Steps * Learn about [signing Ethereum transactions](./sign-ethereum-transactions) * Explore [sending tokens](./send-tokens) * Check out the [Client API Li.Fi endpoints](../../client/reference#lifi-integration) # Presignatures Source: https://docs.portalhq.io/apis/enclave-mpc/guide/presignatures Pre-compute MPC signing data for faster transaction signing with the Enclave MPC API. Presignatures let you front-load part of the MPC computation so that subsequent signing completes faster. For a general overview, see the [Presignatures resource](/resources/presignatures). This guide walks through creating a presignature and using it with the sign, raw sign, and send asset endpoints. The Enclave MPC API supports two presignature storage modes: | Mode | How you create it | What you store | How you sign | | -------------- | -------------------------------------- | ------------------------------------------ | ----------------------------- | | Portal-managed | Set `managed: true` | The returned `id` | Pass `id` as `presignatureId` | | Client-stored | Omit `managed` or set `managed: false` | The opaque `data` blob returned by the API | Pass `data` as `presignature` | Portal-managed presignatures are optional. They are useful when you want Portal to store the encrypted client-side presignature payload so your application only needs to track presignature IDs and expiration times. ### Prerequisites Before starting, make sure you have: 1. [Created a Portal client](/apis/enclave-mpc/guide/create-a-client) 2. [Created a wallet](/apis/enclave-mpc/guide/create-a-wallet) and have access to the `share` data ## Step 1: Create a Presignature Call the presign endpoint with your share and choose whether the presignature should be client-stored or Portal-managed. Presignatures currently only support the `SECP256K1` curve (EVM, Bitcoin). ED25519 (Solana) support is coming soon. ### Option 1: Portal-Managed To let Portal store the encrypted client presignature payload, set `managed` to `true`. ```bash theme={null} curl --request POST \ --url https://mpc-client.portalhq.io/v1/presign/SECP256K1 \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --header 'Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000' \ --data '{ "share": "", "managed": true }' ``` **Headers:** | Header | Required | Description | | ----------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Idempotency-Key` | No | A caller-provided key for safe retries. If creation succeeds but the response is lost, retrying with the same key returns the same `{id, expiresAt}` result without redoing the work. See [idempotency behavior](#idempotency-behavior) below. | **Request body:** | Field | Type | Required | Description | | -------------- | ------- | -------- | ----------------------------------------------------------------------------------- | | `share` | string | Yes | The JSON-serialized MPC share for the wallet. | | `managed` | boolean | No | Set to `true` to create a Portal-managed presignature. Defaults to `false`. | | `expirationTs` | number | No | Unix timestamp for when the presignature expires. Defaults to 7 days. Max 365 days. | **Response:** ```json theme={null} { "id": "", "expiresAt": "2025-03-18T10:00:00Z" } ``` The response does not include `data`. Store the `id` and pass it as `presignatureId` when signing. For presignature creation, idempotency currently applies only to Portal-managed presignatures. It is not currently available for client-stored presignatures. #### Idempotency Behavior Use a fresh `Idempotency-Key` (typically a random UUID) per presign request. The key should only be reused when you are retrying the *same* request after a network failure or lost response. In that case, the API distinguishes a few outcomes: | Situation | Response | | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Original request already completed successfully | `200 OK` with the same `{id, expiresAt}` — no new work is done. | | Original request is still in progress | `409 Conflict` with a `Retry-After` header. Retry after the suggested delay. | | Original request failed before completing | `409 Conflict` without `Retry-After`. Retry with a **new** idempotency key. | | Key reused after the wallet's signing share changed | `422 Unprocessable Entity`. This typically indicates the wallet was re-shared or recovered between the two calls, so the retry is no longer the same logical request. Use a new idempotency key. | #### Security Properties Storing the presignature on Portal's side does not let Portal sign on your behalf. The payload is encrypted inside the enclave with a key derived from your MPC share and the presignature ID. Your share never leaves the enclave, so Portal infrastructure only ever sees ciphertext. When you sign with `presignatureId`, the enclave fetches the encrypted payload and decrypts it using the `share` from the signing request. If that share differs from the one used to create the presignature, decryption fails and the API returns `404`. Portal-managed presignatures are single-use and subject to the usual expiration. Once a presignature has been used to sign, it cannot be reused. ### Option 2: Client-Stored Omit `managed` (or set it to `false`) to create a client-stored presignature. The response includes an opaque `data` blob that you store and pass back when signing. ```bash theme={null} curl --request POST \ --url https://mpc-client.portalhq.io/v1/presign/SECP256K1 \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "share": "" }' ``` **Request body:** | Field | Type | Required | Description | | -------------- | ------ | -------- | ----------------------------------------------------------------------------------- | | `share` | string | Yes | The JSON-serialized MPC share for the wallet. | | `expirationTs` | number | No | Unix timestamp for when the presignature expires. Defaults to 7 days. Max 365 days. | **Response:** ```json theme={null} { "id": "", "expiresAt": "2025-03-18T10:00:00Z", "data": "" } ``` | Field | Type | Description | | ----------- | ------ | --------------------------------------------------------------------------------------- | | `id` | string | Unique identifier for the presignature. | | `expiresAt` | string | RFC 3339 expiration timestamp. | | `data` | string | Opaque presignature payload. Pass this as the `presignature` field in signing requests. | Store the `data` value from the response — you will pass it as the `presignature` field in the next step. It is also recommended to store the `id` and `expiresAt` values for bookkeeping purposes. The `id` may be needed when communicating with Portal support. ## Step 2: Use the Presignature Pass either: * `presignature`: the `data` value from a client-stored presignature response * `presignatureId`: the `id` value from a Portal-managed presignature response These fields are mutually exclusive. ### Raw Sign Sign an arbitrary hex digest string with a presignature. ```bash theme={null} curl --request POST \ --url https://mpc-client.portalhq.io/v1/raw/sign/SECP256K1 \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "params": "", "presignature": "", "share": "" }' ``` | Field | Type | Required | Description | | ---------------- | ------ | -------- | ---------------------------------------------------------------------------------------------- | | `share` | string | Yes | The JSON-serialized MPC share. | | `params` | string | Yes | Hex digest string to sign, **without** a `0x` prefix. | | `presignature` | string | No | The `data` value from a client-stored presign response. | | `presignatureId` | string | No | The `id` value from a Portal-managed presign response. Mutually exclusive with `presignature`. | | `signingScheme` | string | No | `"cggmp"` (default) or `"frost"`. | Presignatures are currently available for `SECP256K1` only. The curve in the URL path (`/v1/raw/sign/{curve}`) must match the curve used when creating the presignature — passing `presignature` or `presignatureId` to `/v1/raw/sign/ED25519` returns an error. For a Portal-managed presignature, pass `presignatureId` instead: ```bash theme={null} curl --request POST \ --url https://mpc-client.portalhq.io/v1/raw/sign/SECP256K1 \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "params": "", "presignatureId": "", "share": "" }' ``` ### Sign (RPC-based) Use a presignature with the RPC-based sign endpoint for blockchain transactions. ```bash theme={null} curl --request POST \ --url https://mpc-client.portalhq.io/v1/sign \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "share": "", "presignature": "", "method": "eth_sendTransaction", "params": {"value": "0x01", "from": "YOUR_ETH_ADDRESS", "to": "RECIPIENT_ADDRESS", "data": ""}, "rpcUrl": "YOUR_RPC_URL", "chainId": "eip155:10143" }' ``` | Field | Type | Required | Description | | ---------------- | ------------------- | -------- | -------------------------------------------------------------------------------------------------- | | `share` | string | Yes | The JSON-serialized MPC share. | | `presignature` | string | No | The `data` value from a client-stored presign response. Omit for standard (non-presigned) signing. | | `presignatureId` | string | No | The `id` value from a Portal-managed presign response. Mutually exclusive with `presignature`. | | `method` | string | Yes | RPC method (e.g., `eth_sendTransaction`, `personal_sign`). | | `params` | string/object/array | Yes | RPC parameters for the transaction. | | `rpcUrl` | string | Yes | RPC endpoint URL. | | `chainId` | string | Yes | CAIP-2 chain ID (e.g., `eip155:10143`). | For a Portal-managed presignature, replace `presignature` with `presignatureId`: ```bash theme={null} curl --request POST \ --url https://mpc-client.portalhq.io/v1/sign \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "share": "", "presignatureId": "", "method": "eth_sendTransaction", "params": {"value": "0x01", "from": "YOUR_ETH_ADDRESS", "to": "RECIPIENT_ADDRESS", "data": ""}, "rpcUrl": "YOUR_RPC_URL", "chainId": "eip155:10143" }' ``` ### Send Assets Use a presignature with the high-level send assets endpoint. ```bash theme={null} curl --request POST \ --url https://mpc-client.portalhq.io/v1/assets/send \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "share": "", "presignature": "", "rpcUrl": "https://api.portalhq.io/rpc/v1/eip155/10143", "chain": "monad-testnet", "to": "0xDestinationAddress", "token": "NATIVE", "amount": "0.0001" }' ``` | Field | Type | Required | Description | | ---------------- | ------ | -------- | ---------------------------------------------------------------------------------------------- | | `share` | string | Yes | The JSON-serialized MPC share. | | `presignature` | string | No | The `data` value from a client-stored presign response. | | `presignatureId` | string | No | The `id` value from a Portal-managed presign response. Mutually exclusive with `presignature`. | | `chain` | string | Yes | Chain name or CAIP-2 ID (e.g., `monad-testnet`). | | `to` | string | Yes | Recipient address. | | `token` | string | Yes | Token identifier or `"NATIVE"` for native transfers. | | `amount` | string | Yes | Amount as a numeric string. | | `rpcUrl` | string | No | RPC endpoint URL. | For a Portal-managed presignature, replace `presignature` with `presignatureId`. ## List and Delete Presignatures Use the list endpoint to see active presignatures for the client API key. The response includes both Portal-managed and client-stored presignatures. ```bash theme={null} curl --request GET \ --url https://mpc-client.portalhq.io/v1/presignatures \ --header 'Authorization: Bearer ' ``` ```json theme={null} { "presignatures": [ { "id": "", "expiresAt": "2025-03-18T10:00:00Z", "curve": "SECP256K1", "managed": true } ] } ``` Delete a presignature when you no longer need it: ```bash theme={null} curl --request DELETE \ --url https://mpc-client.portalhq.io/v1/presignatures/ \ --header 'Authorization: Bearer ' ``` On success, the API returns `204 No Content` with an empty body. After deletion, the presignature can no longer be used for signing — for a client-stored presignature, the locally stored `data` blob for that ID becomes unusable. ## Important Notes * **Single-use**: Each presignature can only be used once. After signing, you must create a new one. * **Active limit**: You can have up to 100 active presignatures per client. Set up a regular process to discard expired presignatures. * **Curve matching**: For raw sign, the presignature's curve must match the URL path curve. * **Expiration**: Presignatures expire after their `expiresAt` timestamp. The default is 7 days; the maximum is 365 days. * **Portal-managed signing**: `presignatureId` only works for Portal-managed presignatures and requires the same MPC share used to create the presignature. ## Additional Resources * [Presignatures overview](/resources/presignatures) — General concepts and use cases * [Send tokens](/apis/enclave-mpc/guide/send-tokens) — Sending tokens without presignatures * [Sign Ethereum transactions](/apis/enclave-mpc/guide/sign-ethereum-transactions) — Standard EVM signing * [Concurrent transactions](/apis/enclave-mpc/guide/concurrent-transactions) — Handling multiple transactions # Send tokens Source: https://docs.portalhq.io/apis/enclave-mpc/guide/send-tokens Here's exactly how you can send tokens from your Portal wallet to another address. ## Fund your Wallet Now that you have a wallet, the next step is to get test tokens for it. You can fund your wallet using the Fund wallet with testnet tokens Client API endpoint. If you are looking for a greater variety of test tokens, we recommend exploring our [faucets page](../../../resources/testnet-faucets). The `chainId` will need to be a [CAIP-2](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md) compliant Chain ID. For more info on Chain ID formatting, see [this doc](../../../resources/chain-id-formatting). ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/fund \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "chainId": "eip155:10143", "token": "NATIVE", "amount": "0.01" }' ``` ## Sending Tokens from your Wallet Portal provides two ways to send transactions: 1. The Send Asset Enclave MPC API endpoint - Simply send tokens from your Portal wallet. 2. The Sign Enclave MPC API endpoint - Create and submit your own custom transactions from your Portal wallet. For most use cases, we recommend using the Send Asset Enclave MPC API endpoint as shown in the examples below. The below example sends `0.0001` ETH on Monad Testnet from your Portal client's wallet. ```sh theme={null} curl --request POST \ --url https://mpc-client.portalhq.io/v1/assets/send \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "share": "SECP256K1.share", "rpcUrl": "https://api.portalhq.io/rpc/v1/eip155/10143", "chain": "monad-testnet", "to": "0xDestinationAddress", "token": "NATIVE", "amount": ".0001" }' ``` The below example sends `0.0001` SOL on Solana Devnet from your Portal client's wallet. You will need **`SOL`** to submit a Solana transaction, which is not currently supported by **`POST /api/v3/clients/me/fund`**. You can find a faucet to get test **`SOL`** tokens [here](../../../resources/testnet-faucets). ```bash theme={null} curl --request POST \ --url https://mpc-client.portalhq.io/v1/assets/send \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "share": "ED25519.share", "rpcUrl": "https://api.portalhq.io/rpc/v1/solana/EtWTRABZaYq6iMfeYKouRu166VU2xqa1", "chain": "solana-devnet", "to": "0xDestinationAddress", "token": "NATIVE", "amount": ".0001" }' ``` The below example sends `0.0001` BTC on Bitcoin Testnet from your Portal client's `p2wpkh` wallet. You will need **`BTC`** to submit a Solana transaction, which is not currently supported by **`POST /api/v3/clients/me/fund`**. You can find a faucet to get test **`BTC`** tokens [here](../../../resources/testnet-faucets). The `chain` request body parameter for sending `BTC` can be one of: * `bitcoin-segwit` - The Portal client's P2WPKH address on Bitcoin mainnet. * `bitcoin-p2wpkh` - The Portal client's P2WPKH address on Bitcoin mainnet. * `bitcoin-segwit-testnet` - The Portal client's P2WPKH address on Bitcoin testnet. * `bitcoin-p2wpkh-testnet` - The Portal client's P2WPKH address on Bitcoin testnet. ```bash theme={null} curl --request POST \ --url https://mpc-client.portalhq.io/v1/assets/send \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "share": "SECP256K1.share", "chain": "bitcoin-segwit-testnet", "to": "tb1qDestinationAddress", "token": "NATIVE", "amount": ".0001" }' ``` You just sent your first token from your Portal wallet, that's awesome! 🎉 **Account Abstraction clients (EVM sends):** for an EVM send, the `transactionHash` returned by `POST /v1/assets/send` is a **UserOperation hash**, not an on-chain transaction hash — it will **not** resolve on a block explorer such as Etherscan or Monadscan. The on-chain transaction hash is only assigned once the bundler includes the UserOperation on-chain. Look up the UserOperation hash on a UserOp explorer such as [JiffyScan](https://jiffyscan.xyz/) to find the resulting transaction hash. Account Abstraction is EVM-only — Solana and Bitcoin sends return a normal on-chain transaction hash. See [Account abstraction](../../../resources/account-abstraction). ## Advanced: Controlling Gas Sponsorship If your client is using [Account Abstraction](../../../resources/account-abstraction), you can control whether Portal sponsors the gas fees for each transaction using the `sponsorGas` parameter. ### Example: User Pays Gas ```bash theme={null} curl --request POST \ --url https://mpc-client.portalhq.io/v1/assets/send \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "share": "SECP256K1.share", "chain": "sepolia", "to": "0xDestinationAddress", "token": "NATIVE", "amount": "0.0001", "rpcUrl": "https://api.portalhq.io/rpc/v1/eip155/11155111", "sponsorGas": false }' ``` By setting `sponsorGas: false`, the Portal client will pay for the transaction fees instead of having them sponsored. This is useful for testing or when you want users to pay for specific operations. Omitting `sponsorGas` or setting it to `true` produces the same behavior - both will sponsor gas if your environment is configured for AA on that chain. Only `sponsorGas: false` changes the default behavior to disable sponsorship. Learn more about gas sponsorship control in the [Account Abstraction guide](../../../resources/account-abstraction#controlling-gas-sponsorship-per-transaction). You may have a more advanced use case than simply sending tokens from your Portal wallet. Next, we will dive into how to build your own custom transaction and also how to sign it. # Sign Ethereum transactions Source: https://docs.portalhq.io/apis/enclave-mpc/guide/sign-ethereum-transactions This guide will walk you through how to sign an Ethereum transaction using the Enclave MPC API. So far you have been able to create a Portal client and create their MPC wallets. Now, lets sign a transaction and send someone some MON tokens over Monad Testnet. ### Steps 1. In order to send your first transaction, you’ll first need to fund your wallet. You can get some test MON tokens by using the [Client API Fund Wallet endpoint](../../client/reference#fund-wallet-with-testnet-tokens). ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/fund \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "chainId": "eip155:10143", "token": "NATIVE", "amount": "0.01" }' ``` 2. Next, we’ll need to format a transaction object. Here is the example transaction: ```ts theme={null} const transaction = { "value": "0x01", "from": "YOUR_ETH_ADDRESS", "to": "RECIPIENT_ADDRESS", "data": "" }; ``` 3. Now that we some testnet ETH, let's send a transaction! We will send our test funds to a Portal test wallet using a single API request to `https://mpc-client.portalhq.io/v1/sign` . 1. You can learn more about Ethereum transactions here, but for this example you only need to know about a few fields: 1. `method`: this defines which Ethereum RPC signer method we want to use. 2. `params` : these are the parameters of your transaction. * `value` : this is the amount of MON measured in wei and hex encoded. * `from`: this is the address of the sender - your wallet in this case. * `to`: this is the address you’re sending to - our test wallet in this case. * `data`: this is used for interacting with smart contracts - we can ignore it. In the example below we are using the `eth_sendTransaction` Ethereum RPC method to instruct the Portal API to both *sign* and *submit* a transaction to the blockchain. ```bash theme={null} curl -X POST 'https://mpc-client.portalhq.io/v1/sign' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer ' \ -d '{ "share": $share, "method": "eth_sendTransaction", "params": {"value": "0x01","from": "YOUR_ETH_ADDRESS","to": "RECIPIENT_ADDRESS","data": ""}, "rpcUrl": "YOUR_RPC_URL", "chainId": "eip155:10143" }' ``` If your wallet is not funded, you will receive a RPC error on this step. Congrats! 🎉 You have successfully created a wallet and signed a transaction! # Sign Solana transactions Source: https://docs.portalhq.io/apis/enclave-mpc/guide/sign-solana-transactions This guide will walk you through how to sign a Solana transaction using the Enclave MPC API. Signing a Solana transaction is identical to how we sign an Ethereum transaction with the exception that we need to pass in the Solana specific RPC URL, chain ID, and method. In the example we are going to update: * `chainID` to `solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z` * `rpcUrl` to `https://api.testnet.solana.com` * `method` to `sol_signAndConfirmTransaction` * `params` to a base64 encoded serialized Solana transaction Here is a short snippet of how to get the `params` using `@solana/web3.js`, for a complete example checkout [this](https://github.com/portal-hq/Enclave-MPC-API-Examples/tree/main/Javascript) example repo: ```ts theme={null} const { Transaction, SystemProgram, LAMPORTS_PER_SOL, } = require('@solana/web3.js'); const tx = new Transaction({ recentBlockhash: blockhash, feePayer: feePayerAddress, }).add( SystemProgram.transfer({ fromPubkey: fromPublicKey, toPubkey: toPublicKey, lamports: LAMPORTS_PER_SOL * 0.01, // 0.01 SOL }), ); // Serialize the transaction to a Buffer const serializedTransaction = tx.serialize({ requireAllSignatures: false }); // Convert the serialized Buffer to a Base64 string const base64Transaction = serializedTransaction.toString('base64'); // Sign the transaction const signResponse = await axios.post( `${PORTAL_MPC_CLIENT_URL}/v1/sign`, { share: share, method: 'sol_signTransaction', params: base64Transaction, chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', }, { headers: { Authorization: `Bearer ${clientApiKey}` }, }, ); ``` Below is an example curl command for signing a Solana message: ```bash theme={null} curl -X POST 'https://mpc-client.portalhq.io/v1/sign' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer ' \ -d '{ "share": "ED25519.share", "method": "sol_signTransaction", "params": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEDqF/lgjmUoeVVHb3ObBSh4S4W1vrG333tiaGvUzWHYDLkuw5hIt53j8IqHX8I+gj28DXUsAE3bgbROXDHpUdhhAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlNc9WKVngB8ANy0SFkCbEM6i7nZKuqAgsFEvoZkO4/ABAgIAAQwCAAAAECcAAAAAAAA=", "chainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" }' ``` # Sign Stellar transactions Source: https://docs.portalhq.io/apis/enclave-mpc/guide/sign-stellar-transactions This guide will walk you through how to sign a Stellar transaction using the Enclave MPC API. Signing a Stellar transaction is identical to how we sign an Ethereum or Solana transaction with the exception that we need to pass in the Stellar specific RPC URL, chain ID, and method. Before you submit a Stellar transaction, ensure that the Stellar account is funded and created on-chain. Failing to do so will result in an `address not found` error. In the example we are going to update: * `chainID` to `stellar:pubnet` * `rpcUrl` to `http://horizon.stellar.org` * `method` to `stellar_sendTransaction` * `params` to a base64 encoded serialized Stellar transaction Here is a short snippet of how to get the `params` using the stellar SDK. Note that in this example we assume the destination address is already created. For a complete example checkout [this](https://github.com/portal-hq/Enclave-MPC-API-Examples/blob/main/Javascript/stellar-sign.js) example repo: ```ts theme={null} const axios = require('axios'); const StellarSdk = require('stellar-sdk'); const rpcUrl = 'https://horizon-testnet.stellar.org'; const server = new StellarSdk.Horizon.Server(rpcUrl); const destinationAddress = "RECIPIENT_ADDRESS" await server.loadAccount(destinationAddress); const sourceAccount = await server.loadAccount("YOUR_STELLAR_ADDRESS"); const transaction = new StellarSdk.TransactionBuilder(sourceAccount, { fee: StellarSdk.BASE_FEE, networkPassphrase: StellarSdk.Networks.TESTNET, // Defaulting to testnet }) .addOperation( StellarSdk.Operation.payment({ destination: destinationAddress, // example destination address asset: StellarSdk.Asset.native(), // Send XLM amount: '1', // amount to send }), ) .setTimeout(30) .build(); // Serialize the transaction to XDR const serializedTransaction = transaction.toXDR() // // Convert the serialized Buffer to a Base64 string const base64Transaction = serializedTransaction.toString('base64'); // // Sign the transaction const signResponse = await axios.post( `${PORTAL_MPC_CLIENT_URL}/v1/sign`, { share: shares.ED25519.share, method: 'stellar_sendTransaction', rpcUrl, params: base64Transaction, chainId: 'stellar:testnet', }, { headers: { Authorization: `Bearer ${clientApiKey}` }, }, ); ``` Below is an example curl command for signing a Stellar message: ```bash theme={null} curl -X POST 'https://mpc-client.portalhq.io/v1/sign' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer ' \ -d '{ "share": "ED25519.share", "method": "stellar_sendTransaction", "params": "BASE64_ENCODED_TRANSACTION", "rpcUrl": "https://horizon-testnet.stellar.org", "chainId": "stellar:testnet" }' ``` ### Supported Methods and ChainIds **Methods:** * `stellar_sendTransaction` **ChainIds:** * `stellar:pubnet` * `stellar:testnet` # Sign Tron transactions Source: https://docs.portalhq.io/apis/enclave-mpc/guide/sign-tron-transactions This guide will walk you through how to sign a Tron transaction using the Enclave MPC API. Signing a Tron transaction is identical to how we sign an Ethereum or Solana transaction with the exception that we need to pass in the Tron specific RPC URL, chain ID, and method. You can get an RPC URL for Tron from [www.trongrid.io](https://www.trongrid.io/). In the example we are going to update: * `chainID` to `tron:nile` * `rpcUrl` to `grpc.nile.trongrid.io:50051?api_key=` * `method` to `tron_sendTransaction` * `params` to a base64 encoded serialized Tron transaction Here is a short snippet of how to get the `params` using `tronweb`, for a complete example checkout [this](https://github.com/portal-hq/Enclave-MPC-API-Examples/blob/main/Javascript/tron-sign.js) example repo: ```ts theme={null} const tnw = require('tronweb'); const tronWeb = new tnw.TronWeb({ fullHost: 'https://nile.trongrid.io', headers: { 'TRON-PRO-API-KEY': 'API_KEY' }, }); const fromTronHexAddress = tronWeb.address.toHex("YOUR_TRON_ADDRESS"); const toHexAddress = 'RECIPIENT_ADDRESS'; const builtTx = new tnw.TransactionBuilder(tronWeb); const transaction = await builtTx.sendTrx( toHexAddress, 1, fromTronHexAddress, ); // // Serialize the transaction const txPb = tronWeb.utils.transaction.txJsonToPb(transaction) const rawData = txPb.getRawData().serializeBinary(); // // Base64 encode the serialized transaction const base64Transaction = Buffer.from(rawData).toString('base64'); // Sign the transaction const signResponse = await axios.post( `${PORTAL_MPC_CLIENT_URL}/v1/sign`, { share: shares.SECP256K1.share, method: 'tron_sendTransaction', rpcUrl, params: base64Transaction, chainId: 'tron:nile', }, { headers: { Authorization: `Bearer ${clientApiKey}` }, }, ); ``` Below is an example curl command for signing a Tron message: ```bash theme={null} curl -X POST 'https://mpc-client.portalhq.io/v1/sign' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer ' \ -d '{ "share": "SECP256K1.share", "method": "tron_sendTransaction", "params": "BASE64_ENCODED_TRANSACTION", "rpcUrl": "YOUR_RPC_URL", "chainId": "tron:nile" }' ``` The RPC URL for Tron uses the GRPC protocol, not HTTP. ### Supported Methods and ChainIds **Methods:** * `tron_sendTransaction` **ChainIds:** * `tron:mainnet` * `tron:nile` * `tron:shasta` # Earn with Yield.xyz Source: https://docs.portalhq.io/apis/enclave-mpc/guide/yield-xyz Learn how to discover, enter, manage, and exit yield opportunities using the Enclave MPC API. Portal's Enclave MPC API provides comprehensive yield opportunities capabilities through the Yield.xyz integration. This guide covers discovering yield opportunities, entering positions, managing existing positions, and exiting yield opportunities. ## Overview The yield functionality allows you to: * **Get defaults** — Portal's curated list of recommended yield opportunities per chain and token, enriched with live data * **Discover** available yield opportunities across different protocols and networks * **Get validators** — fetch available validators for staking yields that require validator selection * **Enter** yield positions by depositing tokens into yield opportunities * **Manage** existing positions (claim rewards, voting, etc.) * **Exit** yield positions to withdraw aggregated tokens and rewards * **Track** yield balances and historical yield actions ## Prerequisites Before using yield operations, ensure you have: * A properly initialized Portal client (see [Create a client](./create-a-client)) * An active wallet with the required token(s) on the target network (see [Create a wallet](./create-a-wallet)) * Yield.xyz integration enabled in your Portal Dashboard (see [Yield.xyz Integration](../../../resources/integrations/yield-xyz)) ## Default Yield Opportunities Portal maintains a curated list of recommended yield opportunities for each supported chain and token. Use the `GET /api/v3/clients/me/integrations/yield-xyz/yields/defaults` endpoint to retrieve these defaults. By default, the response is lightweight — just yield IDs. Pass `includeOpportunities=true` to enrich with live data (APY, TVL, status, etc.) from Yield.xyz. For complete API documentation, see the [Client API reference](../../client/reference#get-default-yield-opportunities). ```bash theme={null} # Get all default yield IDs (lightweight) curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz/yields/defaults' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' # Include full opportunity details curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz/yields/defaults?includeOpportunities=true' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' ``` You can filter by chain and/or token: ```bash theme={null} # Filter by chain (Ethereum mainnet) curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz/yields/defaults?chainId=eip155:1' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' # Filter by token curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz/yields/defaults?token=USDC' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' # Filter + enrich curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz/yields/defaults?chainId=eip155:1&token=ETH&includeOpportunities=true' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' ``` The response `data` is an object keyed by `{caip2ChainId}:{tokenSymbol}`, so you can look up entries directly: ```typescript theme={null} const defaults = response.data // Direct lookup — no iteration needed const ethYield = defaults['eip155:1:ETH'] console.log(ethYield.yieldId) // "ethereum-weth-gtweth-..." // With includeOpportunities=true, opportunity contains live data console.log(ethYield.opportunity?.rewardRate?.total) // e.g. 0.0267 ``` Each entry contains: * `yieldId` — the recommended Yield.xyz yield ID, or `null` if no recommendation exists * `opportunity` — full yield opportunity details when `includeOpportunities=true` is passed, otherwise `null` When using `includeOpportunities=true`, check `opportunity.status.enter` and `opportunity.status.exit` before initiating deposits or withdrawals — if either is `false`, the opportunity is temporarily unavailable. ## Discovering Yield Opportunities Use the `GET /api/v3/clients/me/integrations/yield-xyz/yields` endpoint to find available yield opportunities. For complete API documentation, see the [Client API reference](../../client/reference#get-yield-opportunities). ```bash theme={null} curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz/yields?limit=10' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' ``` Popular, high-quality USDC yield options with no lockups or limits: * USDC Aave V3 Lending: * `base-usdc-aave-v3-lending` * USDC Fluid Vault: * `base-usdc-fusdc-0xf42f5795d9ac7e9d757db633d693cd548cfd9169-4626-vault` * USDC Spark Savings Vault: * `ethereum-usdc-spusdc-0x28b3a8fb53b741a8fd78c0fb9a6b2393d896a43d-4626-vault` ## Getting Validators Some yield opportunities (e.g. native staking) require selecting a validator. You can check this by looking at `mechanics.requiresValidatorSelection` in the yield opportunity response. Use the `GET /api/v3/clients/me/integrations/yield-xyz/yields/{yieldId}/validators` endpoint to fetch available validators. For complete API documentation, see the [Client API reference](../../client/reference#get-yield-validators). ```bash theme={null} curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz/yields/monad-testnet-mon-native-staking/validators?limit=10' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' ``` You can filter validators by name, address, provider, status, or preferred flag: ```bash theme={null} curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz/yields/monad-testnet-mon-native-staking/validators?preferred=true&status=active' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' ``` When entering a yield that requires validator selection, pass the chosen validator's `address` as the `validatorAddress` argument in the enter action request body. ## Entering Yield Positions To enter a yield position, first discover the specific yield, then use the `POST /api/v3/clients/me/integrations/yield-xyz/actions/enter` endpoint to create the action and get transactions. You'll then sign and submit these transactions using the Enclave MPC API. For complete API documentation, see the [Client API reference](../../client/reference#enter-yield-position). For the example below, we will use the yield opportunity with the ID `"ethereum-sepolia-link-aave-v3-lending"`. You'll need both Sepolia `ETH` (for gas) and the Aave V3 Sepolia [`LINK` token](https://sepolia.etherscan.io/token/0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5) (to deposit into the position) on your Portal client. ### Step 1: Fund Your Client (Sepolia testnet only) On testnets, you can fund your Portal client directly via the `POST /api/v3/clients/me/fund` endpoint. Request `ETH` for gas and `LINK` for the Aave V3 deposit: ```bash theme={null} # Fund with Sepolia ETH (for gas) curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/fund \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "chainId": "eip155:11155111", "token": "ETH", "amount": "0.01" }' # Fund with Sepolia LINK (the Aave V3 mock LINK accepted by the lending pool) curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/fund \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "chainId": "eip155:11155111", "token": "LINK", "amount": "1" }' ``` ### Step 2: Create the Enter Action ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz/actions/enter \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "address": "0xYourAddress", "arguments": { "amount": "1" }, "yieldId": "ethereum-sepolia-link-aave-v3-lending" }' ``` Take note of the `transactionId` and `unsignedTransaction` fields in the response's `data.rawResponse.transactions` array items. You will need to use these to track the transaction. ### Step 3: Process and Sign Transactions The response will include an array of transactions. Process them sequentially, sign each using the Enclave MPC API, track it, and wait for on-chain confirmation before proceeding to the next. ```bash theme={null} # Example: Signing the first transaction from the enter action response curl --request POST \ --url https://mpc-client.portalhq.io/v1/sign \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "share": "[share]", "method": "eth_sendTransaction", "params": { "from":"0xFromAddress", "to":"0xToAddress", "data":"0xData" }, "rpcUrl": "https://api.portalhq.io/rpc/v1/eip155/11155111", "chainId": "eip155:11155111" }' ``` ### Step 4: Track Transaction After signing and broadcasting, submit the transaction hash to Yield.xyz: ```bash theme={null} curl --request PUT \ --url https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz/transactions/[transactionId]/submit-hash \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "hash": "[transactionHash]" }' ``` Learn more about processing these transactions sequentially in the [section below](#transaction-processing). ## Checking Yield Balances Retrieve current yield positions and balances using the `POST /api/v3/clients/me/integrations/yield-xyz/yields/balances` endpoint. For complete API documentation, see the [Client API reference](../../client/reference#get-yield-balances). ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz/yields/balances \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "queries": [ { "address": "0xYourAddress", "network": "eip155:11155111" } ] }' ``` We recommend always specifying a `yieldId` on each balance query. When `yieldId` is provided, Yield.xyz can resolve balances directly, so you don't need to call the `track` endpoint after entering or exiting positions. ```json theme={null} { "queries": [ { "address": "0xYourAddress", "network": "eip155:11155111", "yieldId": "ethereum-sepolia-link-aave-v3-lending" } ] } ``` ## Exiting Yield Positions Use the `POST /api/v3/clients/me/integrations/yield-xyz/actions/exit` endpoint to create an exit action, then sign and submit the resulting transactions. For complete API documentation, see the [Client API reference](../../client/reference#exit-yield-position). ```bash theme={null} # Step 1: Create exit action curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz/actions/exit \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "yieldId": "ethereum-sepolia-link-aave-v3-lending", "address": "0xYourAddress", "arguments": { "amount": "0.001" } }' # Step 2: Sign and submit transactions (same process as entering) # Process each transaction from the response sequentially using the Enclave MPC API sign endpoint ``` ## Managing Yield Positions If your Portal client has entered into a yield balance, they may have a yield balance with available `pendingActions`. You can use the `POST /api/v3/clients/me/integrations/yield-xyz/actions/manage` endpoint to perform actions on existing yield positions. For example, if the balance has a `pendingAction` of `WITHDRAW` or `CLAIM_REWARDS`, you can use the `manage` method to withdraw or claim rewards from the yield balance. For complete API documentation, see the [Client API reference](../../client/reference#manage-yield-position). ```bash theme={null} # First, get the balance to find pendingActions curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz/yields/balances \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "queries": [ { "address": "0xYourAddress", "network": "eip155:11155111" } ] }' ``` ```json theme={null} // Example response: { "data": { "rawResponse": { "items": [ { "yieldId": "ethereum-sepolia-link-aave-v3-lending", "balances": [ { "address": "0xYourAddress", "amount": "0.001000027202065699", "amountRaw": "1000027202065699", "type": "active", "token": { "address": "0x3FfAf50D4F4E96eB78f2407c090b72e86eCaed24", "symbol": "aEthLINK", "name": "Aave Ethereum LINK", "decimals": 18, "logoURI": "https://assets.stakek.it/tokens/alink.svg", "network": "eip155:11155111", "isPoints": false }, "pendingActions": [ { "intent": "manage", "type": "WITHDRAW", // 👇 This is the "passthrough" value from the balance's pendingAction item that we will use to create the manage action. "passthrough": "eyJhZGRyZXNzZXMiOnsiYWRkcmVzcyI6IjB4ZD...", "arguments": null } ], "amountUsd": "0.000000", "isEarning": true } ] } ], "errors": [] } } } ``` ```bash theme={null} # Then create the manage action using the pendingAction's type and passthrough curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz/actions/manage \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "action": "WITHDRAW", "address": "0xYourAddress", "passthrough": "eyJhZGRyZXNzZXMiOnsiYWRkcmVzcyI6IjB4ZD...", "yieldId": "ethereum-sepolia-link-aave-v3-lending" }' # Finally, sign and submit transactions (same process as entering/exiting) ``` ## Getting Historical Actions Retrieve the history of yield actions for an address using the `GET /api/v3/clients/me/integrations/yield-xyz/actions` endpoint. For complete API documentation, see the [Client API reference](../../client/reference#get-historical-actions). ```bash theme={null} curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz/actions?address=0xYourAddress' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' ``` ## Transaction Processing Yield operations can require multiple transactions. Process them sequentially, sign each using the Enclave MPC API, track it, and wait for on-chain confirmation (e.g. using `eth_getTransactionReceipt`) before proceeding to the next. For complete API documentation, see the [Client API reference](../../client/reference#submit-transaction-hash) and [get transaction details reference](../../client/reference#get-transaction-details). For account abstraction enabled Portal clients, use `eth_getUserOperationReceipt` instead of `eth_getTransactionReceipt` to wait for confirmation, since signing returns a *user operation hash*, not a transaction hash. If you don't specify a `yieldId` on your balance queries, you'll need to call the `PUT /api/v3/clients/me/integrations/yield-xyz/transactions/[transactionId]/submit-hash` endpoint after each transaction so Yield.xyz can attribute the position. Pass the **transaction hash** (extracted from `response.result.receipt.transactionHash` for AA clients), not the user operation hash. ### Example Transaction Processing Flow Here's a complete example of processing transactions from an enter action: ```bash theme={null} # 1. Create the enter action and get transactions ENTER_RESPONSE=$(curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz/actions/enter \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "address": "0xYourAddress", "arguments": { "amount": "1" }, "yieldId": "ethereum-sepolia-link-aave-v3-lending" }') # Extract transactions from response (parse JSON) # Sort by stepIndex and process in order # 2. For each transaction with status "CREATED" and an unsignedTransaction: # a. Parse the unsignedTransaction JSON # b. Sign and submit using Enclave MPC API curl --request POST \ --url https://mpc-client.portalhq.io/v1/sign \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "share": "[share]", "method": "eth_sendTransaction", "params": { "from":"0xFromAddress", "to":"0xToAddress", "data":"0xData" }, "rpcUrl": "https://api.portalhq.io/rpc/v1/eip155/11155111", "chainId": "eip155:11155111" }' # 3. Extract transaction hash from response and track it curl --request PUT \ --url https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz/transactions/[transactionId]/submit-hash \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "hash": "0x..." }' # 4. Wait for transaction confirmation # Poll eth_getTransactionReceipt using a RPC request. Below we use Alchemy's RPC endpoint for Sepolia as an example. curl --request POST \ --url https://eth-sepolia.g.alchemy.com/v2/[alchemyApiKey] \ --header 'Content-Type: application/json' \ --data '{ "id": 1, "jsonrpc": "2.0", "method": "eth_getTransactionReceipt", "params": [ "0xTransactionHash" ] }' # 5. Repeat steps 2-4 for each subsequent transaction ``` The `unsignedTransaction` field in the Yield.xyz response is a JSON string. Parse it and extract the transaction parameters before passing to the Enclave MPC API sign endpoint. Remove the gas parameters if you want Portal to estimate the gas for you. ## Best Practices 1. **Always check yield availability** before attempting to enter positions 2. **Process transactions sequentially** as yield operations often require multiple steps and are dependent on previous transactions being mined successfully 3. **Handle network errors gracefully** and provide user feedback 4. **Monitor transaction status** and provide progress updates to users 5. **Validate user balances** before initiating yield operations 6. **Track all transactions** using the Yield.xyz submit-hash endpoint to maintain accurate state ## Supported Networks The yield functionality supports various networks including: * Monad (`eip155:143`) * Monad Testnet (`eip155:10143`) * Arbitrum (`eip155:42161`) * Avalanche C (`eip155:43114`) * Base (`eip155:8453`) * Base Sepolia (`eip155:84532`) * Celo (`eip155:42220`) * Core (`eip155:1116`) * Ethereum (`eip155:1`) * Ethereum Sepolia (`eip155:11155111`) * Fantom (`eip155:250`) * Gnosis (`eip155:100`) * Harmony (`eip155:1666600000`) * Hyperevm (`eip155:999`) * Katana (`eip155:747474`) * Linea (`eip155:59144`) * Moonriver (`eip155:1285`) * Optimism (`eip155:10`) * Optimism Sepolia (`eip155:11155420`) * Plasma (`eip155:9745`) * Polygon (`eip155:137`) * Polygon Amoy (`eip155:80002`) * Sonic (`eip155:146`) * Unichain (`eip155:130`) * Viction (`eip155:88`) * zkSync (`eip155:324`) * Solana (`solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`) * Solana Devnet (`solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`) * Stellar (`stellar:pubnet`) * Stellar Testnet (`stellar:testnet`) * Tron (`tron:mainnet`) ## Next Steps * Learn about [signing Ethereum transactions](./sign-ethereum-transactions) * Explore [sending tokens](./send-tokens) * Check out the [Client API Yield.xyz endpoints](../../client/reference#yield-xyz-integration) # Lend and borrow with Yield.xyz Source: https://docs.portalhq.io/apis/enclave-mpc/guide/yield-xyz-borrow Learn how to supply collateral, borrow, repay and withdraw on lending protocols using the Enclave MPC API and the Yield.xyz integration. Portal's Enclave MPC API lets your users lend and borrow on Aave V3, Morpho Blue, SparkLend and Lista through the Yield.xyz integration. This guide covers discovering lending markets, building supply / borrow / repay / withdraw actions, signing and submitting the resulting transactions, and tracking positions. ## Overview The lend & borrow functionality allows you to: * **Discover** the lending integrations (protocols) and the markets your project has enabled, with live rates, LTVs, liquidation thresholds and liquidity * **Supply** tokens as collateral and **borrow** against them * **Repay** debt and **withdraw** collateral, or toggle a supplied asset as collateral * **Track** positions (supply and debt balances, health factor, LTV and available borrow) and liquidation history To summarize: a user supplies collateral, can borrow up to the market's `maxLtv` of its value, pays a floating borrow rate with no term, and is liquidated (partially, with a penalty) only if the position's `healthFactor` drops below `1`. The [integration page](/integrations/Yield/yield-xyz-borrow#how-lending-and-borrowing-works) explains the mechanics and why you might offer it. All endpoints live under `https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz-borrow` and return `{ "data": ... }`. Chain ids are [CAIP-2](/resources/chain-id-formatting) everywhere (`eip155:8453` for Base). ## Prerequisites Before using lend & borrow operations, ensure you have: * A properly initialized Portal client (see [Create a client](./create-a-client)) * An active wallet with the required token(s) **and gas** on the target network (see [Create a wallet](./create-a-wallet)) * Yield.xyz integration enabled in your Portal Dashboard, with the lending markets you want to offer enabled in the Yield.xyz dashboard (see [Yield.xyz Borrow](/integrations/Yield/yield-xyz-borrow)) Lending integrations are live on **mainnets only**: there are no Sepolia, Base Sepolia or Amoy markets, so `POST /api/v3/clients/me/fund` cannot be used here. Test with small amounts on an inexpensive chain such as Base. The examples below use Aave V3 USDC on Base. ## Discovering Integrations Use the `GET /api/v3/clients/me/integrations/yield-xyz-borrow/integrations` endpoint to list the lending protocols, the networks each supports and the actions each exposes (including the argument schema per action). For complete API documentation, see the [API reference](/api-reference/yieldxyz-borrow/list-integrations). ```bash theme={null} curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz-borrow/integrations' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' ``` Integration ids look like `aave-borrow`, `morpho-blue-borrow`, `sparklend-borrow` and `lista-borrow`; use the ids the endpoint returns rather than a fixed list. Use `GET .../integrations/{integrationId}` for one integration. ## Discovering Markets Use the `GET /api/v3/clients/me/integrations/yield-xyz-borrow/markets` endpoint to list lending markets. By default only markets you enabled in the Yield.xyz dashboard are returned (`scope=enabled`); pass `scope=all` to browse everything Yield.xyz supports. For complete API documentation, see the [API reference](/api-reference/yieldxyz-borrow/list-lending-markets). ```bash theme={null} # Enabled Aave V3 markets on Base curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz-borrow/markets?integrationId=aave-borrow&network=eip155:8453' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' # Browse every market Yield.xyz supports curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz-borrow/markets?scope=all&limit=50' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' ``` Each market carries the fields you need to render a lending UI: `loanToken`, `collateralTokens[]` (with `maxLtv`, `liquidationThreshold`, `liquidationPenalty`, `supplyRate`), `borrowRate`, `availableLiquidity`, `utilizationRate`, `isBorrowEnabled`, `minLoan` and the fee settings. Treat market ids as opaque values returned by `GET /markets` (for example `aave-borrow-base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913` for USDC on Base); they embed the Yield.xyz network slug, not the CAIP-2 chain id, so do not build them yourself. If `scope=enabled` returns an empty list, no markets are enabled for your project yet. Enable them in the Yield.xyz dashboard, the same way you enable yield opportunities. Enabling only affects what `GET /markets` lists by default; actions can be built for any market id. ## Supplying Collateral To supply, use the `POST /api/v3/clients/me/integrations/yield-xyz-borrow/actions` endpoint to create the action and get the transaction(s) to sign. You'll then sign each with the Enclave MPC API and report the hash back. For complete API documentation, see the [API reference](/api-reference/yieldxyz-borrow/execute-a-borrow-action). ### Step 1: Create the Supply Action ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz-borrow/actions \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "integrationId": "aave-borrow", "action": "supply", "address": "0xYourAddress", "args": { "marketId": "aave-borrow-base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", "tokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "amount": "5" } }' ``` Pool-based protocols (Aave) require `tokenAddress` on `supply` and `withdraw`: the market's `loanToken.address` to supply the loan asset, or one of `collateralTokens[].token.address` to supply a different collateral. Isolated-market protocols (Morpho Blue) infer it from `marketId`. `GET .../integrations/{integrationId}` returns the exact JSON schema for every action's `args`. ```json theme={null} // Example response (trimmed) { "data": { "id": "act_...", "integrationId": "aave-borrow", "action": "supply", "address": "0xYourAddress", "status": "CREATED", "hasNextStep": false, "currentStep": 1, "totalSteps": 1, "transactions": [ { "id": "73729c4d-...", "network": "eip155:8453", "type": "APPROVAL", "status": "CREATED", "address": "0xYourAddress", "signingFormat": "EVM_TRANSACTION", "signablePayload": "{\"from\":\"0xYourAddress\",\"gasLimit\":\"56240\",\"to\":\"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\",\"data\":\"0x095ea7b3...\"}" }, { "id": "5925b16e-...", "network": "eip155:8453", "type": "SUPPLY", "status": "CREATED", "address": "0xYourAddress", "signingFormat": "EVM_TRANSACTION", "signablePayload": "{\"from\":\"0xYourAddress\",\"gasLimit\":\"420000\",\"to\":\"0xA238Dd80C259a72e81d7e4664a9801593F98d1c5\",\"data\":\"0x617ba037...\"}" } ], "metadata": { "currentHealthFactor": null, "predictedHealthFactor": null, "currentLtv": "0", "predictedLtv": "0", "predictedTotalSupplyUsd": "5.00", "predictedTotalDebtUsd": "0" } } } ``` Take note of `data.id` (the action id), each transaction's `id`, and `hasNextStep`. A step can contain several transactions (here the ERC-20 approval and the supply), which you sign and submit **in order**, waiting for each to confirm. Some actions add further steps (`hasNextStep: true`), fetched with the step endpoint below. Yield.xyz builds the transactions even if the wallet does not hold the tokens yet; the balance check happens on-chain. ### Step 2: Sign and Broadcast with the Enclave MPC API `signablePayload` for `EVM_TRANSACTION` is a JSON string of the unsigned transaction. Parse it and pass the fields to the Enclave MPC API `eth_sendTransaction` method, which signs **and** broadcasts, returning the transaction hash. ```bash theme={null} curl --request POST \ --url https://mpc-client.portalhq.io/v1/sign \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "share": "[share]", "method": "eth_sendTransaction", "params": { "from": "0xYourAddress", "to": "0x8335...", "data": "0x095ea7b3...", "value": "0x0" }, "rpcUrl": "https://api.portalhq.io/rpc/v1/eip155/8453", "chainId": "eip155:8453" }' ``` Remove the gas parameters from the parsed payload if you want Portal to estimate gas for you. If you prefer to broadcast yourself, use `eth_signTransaction` instead and send the signed raw transaction as `signedPayload` in the next step, and Yield.xyz will broadcast it. ### Step 3: Submit the Transaction Hash Report the hash to Yield.xyz with `POST /api/v3/clients/me/integrations/yield-xyz-borrow/transactions/{transactionId}/submit` so it can track confirmation and unlock the next step. For complete API documentation, see the [API reference](/api-reference/yieldxyz-borrow/submit-signed-transaction). ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz-borrow/transactions/[transactionId]/submit \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "transactionHash": "0x..." }' ``` The response includes the transaction `status` and an explorer `link`. ### Step 4: Fetch the Next Step When the action has `hasNextStep: true`, wait for the submitted transaction to confirm, then call `POST /api/v3/clients/me/integrations/yield-xyz-borrow/actions/{id}/step` to get the next transaction(s) and repeat steps 2–3. You can check progress at any time with `GET .../actions/{id}`. For complete API documentation, see the [API reference](/api-reference/yieldxyz-borrow/get-next-step-transactions). ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz-borrow/actions/[actionId]/step \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' ``` The action is complete when `status` is `SUCCESS` and `hasNextStep` is `false`. ## Borrowing Borrowing uses the same action flow with `"action": "borrow"`. Supplied assets on Aave are collateral by default; on other integrations, or if you disabled it, run an `enableCollateral` action first. You can also combine both in one flow with `supplyAndBorrow`. ```bash theme={null} # Borrow 2 USDC against your collateral curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz-borrow/actions \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "integrationId": "aave-borrow", "action": "borrow", "address": "0xYourAddress", "args": { "marketId": "aave-borrow-base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", "amount": "2" } }' # Or supply collateral and borrow in one action curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz-borrow/actions \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "integrationId": "aave-borrow", "action": "supplyAndBorrow", "address": "0xYourAddress", "args": { "marketId": "aave-borrow-base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", "collateralTokenAddress": "0x4200000000000000000000000000000000000006", "collateralAmount": "0.01", "borrowAmount": "10" } }' ``` Every action response carries `metadata.predictedHealthFactor` and `metadata.predictedLtv`. Show these to the user before they sign: a health factor at or below `1` means the position can be liquidated. The exact `args` each action accepts (`amount`, `amountRaw`, `repayAll`, `tokenAddress`, `targetLtv` and so on) are described per integration in `GET .../integrations/{integrationId}`. ## Checking Positions Retrieve a user's position on an integration + network with the `GET /api/v3/clients/me/integrations/yield-xyz-borrow/positions` endpoint. For complete API documentation, see the [API reference](/api-reference/yieldxyz-borrow/get-positions). ```bash theme={null} curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz-borrow/positions?integrationId=aave-borrow&network=eip155:8453&address=0xYourAddress' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' ``` ```json theme={null} // Example response (trimmed) { "data": { "address": "0xYourAddress", "integrationId": "aave-borrow", "network": "eip155:8453", "totalSuppliedUsd": "5.00", "totalCollateralUsd": "5.00", "totalBorrowedUsd": "2.00", "netWorthUsd": "3.00", "healthFactor": "1.93", "currentLtv": "0.40", "availableToBorrowUsd": "1.85", "netApy": "0.021", "supplyBalances": [ { "marketId": "aave-borrow-base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", "tokenSymbol": "USDC", "balance": "5.000123", "balanceUsd": "5.00", "apy": "0.038", "isCollateral": true, "pendingActions": [ { "type": "withdraw", "label": "Withdraw", "args": { "marketId": "aave-borrow-base-0x8335..." } } ] } ], "debtBalances": [ { "marketId": "aave-borrow-base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", "tokenSymbol": "USDC", "balance": "2.000041", "balanceUsd": "2.00", "apy": "0.051", "pendingActions": [ { "type": "repay", "label": "Repay", "args": { "marketId": "aave-borrow-base-0x8335..." } } ] } ] } } ``` Each balance lists `pendingActions`, the actions currently available for that balance, with pre-filled `args` you can pass straight to `POST .../actions`. ## Repaying and Withdrawing Repay debt with `"action": "repay"` (pass `"repayAll": true` to clear the position, which avoids dust from accrued interest), then withdraw collateral with `"action": "withdraw"`. Both follow the same sign → submit → step flow. ```bash theme={null} # Repay everything curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz-borrow/actions \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "integrationId": "aave-borrow", "action": "repay", "address": "0xYourAddress", "args": { "marketId": "aave-borrow-base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", "repayAll": true } }' # Withdraw collateral curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz-borrow/actions \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "integrationId": "aave-borrow", "action": "withdraw", "address": "0xYourAddress", "args": { "marketId": "aave-borrow-base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", "tokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "amount": "5" } }' ``` ## Action History and Liquidations List a user's past actions with `GET /api/v3/clients/me/integrations/yield-xyz-borrow/actions?address=0xYourAddress` (filter by `integrationId`, `action` or `status`), and their liquidation history with `GET .../positions/liquidations?integrationId=morpho-blue-borrow&network=eip155:1&address=0xYourAddress` (Morpho Blue integrations only). For complete API documentation, see the [actions](/api-reference/yieldxyz-borrow/list-actions) and [liquidations](/api-reference/yieldxyz-borrow/get-liquidation-history) references. ## Transaction Processing Lend & borrow actions can require multiple transactions across multiple steps. Process them sequentially: sign each with the Enclave MPC API, submit its hash, wait for on-chain confirmation (e.g. `eth_getTransactionReceipt`), then request the next step. ```bash theme={null} # 1. Create the action and read data.transactions (and data.hasNextStep) # 2. For each transaction that is not yet CONFIRMED/SKIPPED/FAILED (fresh ones are CREATED), in array order: # a. Parse signablePayload (JSON string) into { from, to, data, value } # b. Sign + broadcast with the Enclave MPC API curl --request POST \ --url https://mpc-client.portalhq.io/v1/sign \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "share": "[share]", "method": "eth_sendTransaction", "params": { "from": "0xYourAddress", "to": "0x...", "data": "0x...", "value": "0x0" }, "rpcUrl": "https://api.portalhq.io/rpc/v1/eip155/8453", "chainId": "eip155:8453" }' # c. Submit the hash curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz-borrow/transactions/[transactionId]/submit \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "transactionHash": "0x..." }' # d. Wait for confirmation curl --request POST \ --url https://api.portalhq.io/rpc/v1/eip155/8453 \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "id": 1, "jsonrpc": "2.0", "method": "eth_getTransactionReceipt", "params": ["0xTransactionHash"] }' # 3. If hasNextStep is true, POST .../actions/[actionId]/step and repeat from 2 ``` For account abstraction enabled Portal clients, `eth_sendTransaction` returns a *user operation hash*. Resolve it with `GET /api/v3/clients/me/chains/{chain}/transactions/{userOpHash}` and submit `evmUserOperation.receipt.hash` (the on-chain **transaction hash**) to Yield.xyz, never the user operation hash. Use the smart-account address (the client's `eip155` address) as `address` in every Borrow call. ## Handling Errors Yield.xyz Borrow errors are returned as Portal errors: * `400`: invalid request (the message names the field), an unsupported chain id, a request Yield.xyz rejected (its message is passed through), or a Yield.xyz API key that is missing or was rejected * `404`: unknown market, integration, action or transaction id * `429`: Yield.xyz rate limit hit; back off and retry * `503`: Yield.xyz is unreachable or failed upstream * `500` with `id: INTEGRATION_RESPONSE_SCHEMA_DRIFT`: Yield.xyz changed a response shape and Portal did not forward it. Treat it as a temporary outage of that feature; see [Error codes](/resources/error-codes) Response enumerations (action and transaction statuses, action types) may gain values over time; treat values you do not recognize as opaque rather than failing. ## Best Practices 1. **Show the predicted health factor and LTV** from `metadata` before the user signs a borrow or withdraw 2. **Process transactions sequentially** and wait for confirmation before requesting the next step 3. **Use `repayAll`** when closing a position so accrued interest does not leave dust 4. **Enable only the markets you support** in the Yield.xyz dashboard so `GET /markets` returns exactly what your UI offers 5. **Handle `INTEGRATION_RESPONSE_SCHEMA_DRIFT`** as a feature outage rather than a user error 6. **Keep gas on the wallet**: every step is an on-chain transaction ## Supported Networks Lending integrations run on mainnets only; the exact list per protocol is returned by `GET /api/v3/clients/me/integrations/yield-xyz-borrow/integrations` (`networks[]`, as CAIP-2). Aave V3 covers the major EVM chains (Ethereum `eip155:1`, Base `eip155:8453`, Arbitrum `eip155:42161`, Optimism `eip155:10`, Polygon `eip155:137`, Avalanche `eip155:43114`, and more); Morpho Blue covers Ethereum, Base and other EVM chains; SparkLend is Ethereum-only; Lista runs on BNB Chain (`eip155:56`) and Ethereum. ## Next Steps * Set up the integration: [Yield.xyz Borrow](/integrations/Yield/yield-xyz-borrow) * Learn about [signing Ethereum transactions](./sign-ethereum-transactions) * Check out the [Yield.xyz Borrow API reference](/api-reference/yieldxyz-borrow/get-positions) # Cross-Chain Swap with 0x Source: https://docs.portalhq.io/apis/enclave-mpc/guide/zerox-cross-chain Learn how to bridge and swap tokens across chains using the Enclave MPC API with 0x cross-chain integration. Cross-chain swap endpoints are experimental and require explicit enablement by both the Portal and 0x teams before they can be used. Contact your Portal account manager to request access. Portal's Enclave MPC API provides cross-chain bridging and swapping through 0x. This guide covers getting quotes, executing swaps, and tracking transaction status across chains. ## Overview Using 0x cross-chain, you can: * **Get quotes** for cross-chain swaps across EVM chains with multiple bridge providers * **Execute cross-chain swaps** by signing and submitting the returned transaction * **Track transaction status** for cross-chain transfers * **Browse transaction history** for a wallet address * **List available sources** including bridge providers and DEX sources ## Prerequisites Before using 0x cross-chain operations, ensure you have: * A properly initialized Portal client (see [Create a client](./create-a-client)) * An active wallet with the required token(s) on the origin chain (see [Create a wallet](./create-a-wallet)) * 0x integration enabled in your Portal Dashboard with a cross-chain-enabled API key (see [0x Integration](../../../integrations/Trading/zerox)) ## Getting Cross-Chain Quotes Use the `POST /api/v3/clients/me/integrations/0x/cross-chain/quotes` endpoint to get quotes for a cross-chain swap. ```bash theme={null} curl --request POST \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/0x/cross-chain/quotes' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "originChain": "eip155:8453", "destinationChain": "eip155:42161", "sellToken": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", "buyToken": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", "sellAmount": "1000000", "sortQuotesBy": "price" }' ``` ### Parameters | Name | Type | Required | Description | | -------------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `originChain` | string | Yes | Origin chain in CAIP-2 format (e.g. `eip155:8453`) | | `destinationChain` | string | Yes | Destination chain in CAIP-2 format (e.g. `eip155:42161`) | | `sellToken` | string | Yes | Token to sell on the origin chain. Accepts a contract address (e.g. `0x833589...`), a native token keyword (`NATIVE`, `ETH`, `AVAX`, `POL`, etc.), or a supported token symbol (`USDC`, `USDT`) | | `buyToken` | string | Yes | Token to buy on the destination chain. Same formats as `sellToken` | | `sellAmount` | string | Yes | Amount of `sellToken` to sell, in base units (the smallest indivisible unit of the token, e.g. `1000000` for 1 USDC since USDC has 6 decimals, or wei for native ETH) | | `sortQuotesBy` | string | Yes | Sort quotes by `speed` or `price` | | `originAddress` | string | No | Wallet address on origin chain. Defaults to the client's EIP-155 address | | `destinationAddress` | string | No | Wallet address to receive tokens. Defaults to `originAddress` | | `slippageBps` | number | No | Maximum slippage in basis points (100 = 1%). Defaults to 100 | | `maxNumQuotes` | number | No | Maximum number of quotes to return (1-10). Defaults to 3 | | `excludedBridges` | string | No | Comma-separated bridge providers to exclude | | `includedBridges` | string | No | Comma-separated bridge providers to include | | `feeBps` | string | No | Integrator fee in basis points | | `feeRecipient` | string | No | Address to receive integrator fees | ### Response The response includes an array of `quotes`, each containing: * `sellAmount` / `buyAmount` / `minBuyAmount` - Token amounts in base units * `steps` - Sequential steps (swap, bridge, wrap/unwrap) required to complete the cross-chain swap * `transaction` - A Portal-formatted transaction object (`from`, `to`, `data`, `gas`, `gasPrice`, `value`) ready to sign and submit * `approvalTransaction` - A Portal-formatted approval transaction (included automatically when token allowance is needed on EVM origin chains). Sign and submit this before the main `transaction` * `estimatedTimeSeconds` - Estimated time for the swap to complete * `fees` - Breakdown of integrator fees, 0x fees, and bridge fees * `issues` - Problems that would cause the swap to fail (allowance, balance, simulation, invalid sources). See [Inspecting `issues`](#inspecting-issues) below When `issues.allowance` is present, the response automatically includes an `approvalTransaction` that approves the exact `sellAmount` for the swap. This uses the minimum required approval for security — no unlimited approvals. ### Inspecting `issues` Before submitting any transactions, inspect the quote's `issues` property. It surfaces problems that would otherwise cause the swap to fail on-chain: * `allowance` - The wallet hasn't approved enough of `sellToken` for the 0x contract. Sign the included `approvalTransaction` (covered in Step 1 below). * `balance` - The wallet doesn't hold enough `sellToken` to cover `sellAmount`. Top up the wallet before retrying. * `simulationIncomplete` - 0x couldn't fully simulate the swap, so execution may revert. Re-fetch the quote, or proceed with caution if the route is otherwise sound. * `invalidSourcesPassed` - One of the bridges or DEX sources you requested via `includedBridges` isn't valid for this route. Adjust `includedBridges` / `excludedBridges` and re-fetch. Resolve any issues (top up the wallet, exclude a problematic source, etc.), then fetch a new quote and proceed with the steps below. ## Executing a Cross-Chain Swap After selecting a quote, check if an `approvalTransaction` is present. If so, you must sign and submit it **before** the main `transaction`. This is a two-step signing flow: 1. **Sign `approvalTransaction`** (if present) — approves the 0x contract to spend your ERC20 tokens 2. **Re-fetch a fresh quote** — the original quote may have expired while the approval was confirming 3. **Sign `transaction`** — executes the cross-chain swap If `approvalTransaction` is present in the quote response, you **must** sign and submit it before the main `transaction`. Submitting the swap transaction without approval will fail with an "ERC20: transfer amount exceeds allowance" error. ### Step 1: Approve Token Allowance (if required) If the quote includes `approvalTransaction`, sign and submit it first. This approves the 0x contract to spend the exact amount of your sell token needed for the swap. ```bash theme={null} curl --request POST \ --url https://mpc-client.portalhq.io/v1/sign \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "share": "[share]", "method": "eth_sendTransaction", "params": [approvalTransaction], "rpcUrl": "https://api.portalhq.io/rpc/v1/eip155/8453", "chainId": "eip155:8453" }' ``` Pass the entire `approvalTransaction` object from the quote response as `params`. Wait for the approval transaction to confirm before proceeding to the next step. Native token swaps (e.g. ETH, AVAX) do not require approval since they are not ERC20 tokens. The `approvalTransaction` field will only be present when swapping ERC20 tokens that require allowance. ### Step 2: Re-fetch a Fresh Quote After the approval confirms, fetch a new quote. The original quote contains calldata with embedded timestamps that may have expired during the approval confirmation. ```bash theme={null} # Re-fetch quote with the same parameters as Step 1 curl --request POST \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/0x/cross-chain/quotes' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "originChain": "eip155:8453", "destinationChain": "eip155:42161", "sellToken": "USDC", "buyToken": "USDC", "sellAmount": "1000000", "sortQuotesBy": "price" }' ``` The new quote should no longer include `approvalTransaction` (since the allowance is now set) and the `transaction` calldata will have fresh timestamps. ### Step 3: Sign and Submit the Cross-Chain Transaction Use the `transaction` from the fresh quote: ```bash theme={null} curl --request POST \ --url https://mpc-client.portalhq.io/v1/sign \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "share": "[share]", "method": "eth_sendTransaction", "params": [transaction], "rpcUrl": "https://api.portalhq.io/rpc/v1/eip155/8453", "chainId": "eip155:8453" }' ``` Pass the entire `transaction` object from the quote response as `params`. The `rpcUrl` and `chainId` should correspond to the **origin chain**, since the transaction is submitted on the origin chain. The bridge provider handles delivering tokens to the destination chain. ### Step 4: Resolve the Transaction Hash (Account Abstraction clients only) If your client uses Account Abstraction, the `eth_sendTransaction` response contains a [user operation hash](https://eips.ethereum.org/EIPS/eip-4337) rather than a transaction hash. The cross-chain status endpoint requires the actual on-chain transaction hash, so resolve the userOp hash first: ```bash theme={null} curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/chains/eip155:8453/transactions/[userOpHash]' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' ``` Use `evmUserOperation.receipt.hash` from the response as the `originTxHash` in Step 5. EOA clients can skip this step and use the hash returned from signing directly. See the [transaction lookup endpoint](https://docs.portalhq.io/api-reference/wallet/get-transaction-details-by-signature) for the full response shape. ### Step 5: Track Transaction Status Use the `GET /api/v3/clients/me/integrations/0x/cross-chain/status` endpoint to track the cross-chain transfer: ```bash theme={null} curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/0x/cross-chain/status?originChain=eip155:8453&originTxHash=0xYourTransactionHash' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' ``` ### Status Values | Status | Description | | --------------------- | ---------------------------------------------------------- | | `origin_tx_pending` | Origin chain transaction is pending | | `origin_tx_succeeded` | Origin chain transaction succeeded | | `origin_tx_confirmed` | Origin chain transaction is confirmed | | `origin_tx_reverted` | Origin chain transaction reverted | | `bridge_pending` | Bridge transfer is in progress | | `bridge_filled` | Bridge transfer completed successfully | | `bridge_failed` | Bridge transfer failed (check `failure` field for details) | If the status is `bridge_failed`, check the `failure` field in the response. In some cases (e.g. Stargate V2), a `failure.recovery.manualTransaction` may be provided that you can submit to recover funds on the destination chain. ## Listing Available Sources Use the `GET /api/v3/clients/me/integrations/0x/cross-chain/sources` endpoint to list available bridge providers and DEX sources: ```bash theme={null} curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/0x/cross-chain/sources' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' ``` The response includes: * `bridges` - Available bridge providers with supported origin/destination chain pairs * `swapSources` - Available DEX sources with supported chain IDs ## Viewing Transaction History Use the `GET /api/v3/clients/me/integrations/0x/cross-chain/tx-history` endpoint to view cross-chain transaction history for a wallet: ```bash theme={null} curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/0x/cross-chain/tx-history?user=0xYourWalletAddress&limit=20' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' ``` ## Example Flow Here's a complete example of a cross-chain swap from Base USDC to Arbitrum USDC: ```bash theme={null} # 1. Get cross-chain quotes QUOTE_RESPONSE=$(curl --request POST \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/0x/cross-chain/quotes' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "originChain": "eip155:8453", "destinationChain": "eip155:42161", "sellToken": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", "buyToken": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", "sellAmount": "1000000", "sortQuotesBy": "price" }') # 2. Inspect quote.issues, then select a quote and pass its `transaction` # object straight through as `params` below. # 3. Sign and submit the transaction on the origin chain curl --request POST \ --url https://mpc-client.portalhq.io/v1/sign \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "share": "[share]", "method": "eth_sendTransaction", "params": [transaction], "rpcUrl": "https://api.portalhq.io/rpc/v1/eip155/8453", "chainId": "eip155:8453" }' # 4. Track the cross-chain transfer status (AA clients: first resolve the # userOp hash to a tx hash via GET /clients/me/chains/{chain}/transactions/{userOpHash}) curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/0x/cross-chain/status?originChain=eip155:8453&originTxHash=0xYourTransactionHash' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' ``` ## Failure Handling Cross-chain operations are non-atomic. If a bridge transfer fails: * **Automatic refunds**: For Across, Relay, and Squid Coral, the user will be refunded in the bridging token (which may differ from the original sell token) on the origin chain. * **Manual recovery**: For Stargate V2, funds may be delivered to the destination chain but not to the wallet. The status API will return a `failure.recovery.manualTransaction` that can be submitted to re-trigger delivery. Always monitor transaction status after submission and implement appropriate error handling for your users. ## Supported Networks 0x cross-chain supports bridging between the following EVM networks: * Ethereum (`eip155:1`) * Base (`eip155:8453`) * Arbitrum (`eip155:42161`) * Optimism (`eip155:10`) * Polygon (`eip155:137`) * BSC (`eip155:56`) * Avalanche (`eip155:43114`) * Linea (`eip155:59144`) * Blast (`eip155:81457`) * Scroll (`eip155:534352`) Use the [sources endpoint](#listing-available-sources) to get the full list of supported bridge pairs. ## Next Steps * Learn about [signing Ethereum transactions](./sign-ethereum-transactions) * Explore same-chain [0x swaps](/integrations/Trading/zerox) using the SDKs * Compare with [Li.Fi cross-chain bridging](./lifi) for alternative bridge options # API Quickstart Source: https://docs.portalhq.io/apis/quickstart This quick start guide will get you up and running with the Portal MPC API. # API Quick Start ## Overview The Enclave MPC API lives at `mpc-client.portalhq.io`. This API allows you create, backup, and recover Portal MPC Wallets. It also allows you to sign messages and transactions for EVM and Solana chains. The Enclave MPC API at `mpc-client.portalhq.io` lets you create, backup, and recover Portal MPC wallets, and sign transactions for EVM and Solana chains. This guide gets you started quickly — see the [Enclave MPC API Guide](./enclave-mpc/guide/create-a-client) for details. ## Authentication Initialize the API with a Client API Key or Client Session Token via the `Authorization: Bearer` header. You can get a test Client API Key from the [Portal Admin Dashboard](https://app.portalhq.io) in the **`Settings`** -> **`Test Client API Keys`** section. Simply click the **`New +`** button. A modal will then be presented, allowing you to copy your test Client API Key. You can repeat this process as many times as you want to. ## Create a wallet Generate a wallet with the `/v1/generate` endpoint: ```bash theme={null} curl -X POST 'https://mpc-client.portalhq.io/v1/generate' \ -H 'Content-type: application/json' \ -H 'Authorization: Bearer ' \ -d '{}' ``` You’ll receive wallet shares for `SECP256K1` (EVM) and `ED25519` (Solana) curves. Example response: ```json theme={null} { "SECP256K1": { "share": "eyJjbGllbnRJZCI6IiIsImJhY2t1cFNoYXJlUGFpcklkIjoiIiwic2lnbml...", "id": "clu3aue3j001fs60wdecyz0qy" }, "ED25519": { "share": "zMzMzQ4MzIyMDUwMDYwMDc1NzU2NDYzMTU1MzEyODg3MzY5MjQxNTc2lnbml...", "id": "clu3auej6001ds60wqhh4tzgx" } } ``` ### Confirm the shares are stored successfully Next, store the generate response and confirm with Portal that you did so successfully with a `PATCH` request. Pass in the `id` values for each of the `SECP256K1` and `ED25519` objects. In your production build, be sure to store the full response object in a secure location. For this guide, just save the shares off to the side. ```bash theme={null} curl -X PATCH 'https://api.portalhq.io/api/v3/clients/me/signing-share-pairs' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer ' \ -d '{ "status": "STORED_CLIENT", "signingSharePairIds": ["", ""] }' ``` Great work! Your Portal client's wallet is now all set up to both receive and send tokens. ## Retrieve your wallet's addresses To view the addresses of your Portal client (along with many other details), simply make a request to the [Get Client Details](./client/reference#get-client-details) endpoint. ```bash theme={null} curl 'https://api.portalhq.io/api/v3/clients/me' \ -H 'Authorization: Bearer ' ``` Check out the [Get Client Details API reference](./client/reference#get-client-details) to learn about the other values that are returned, but for this guide we'll focus on the `metadata.namespaces` property, as allows you to easily access your wallet's addresses on various blockchains. **Example Response** ```json theme={null} { ..., "metadata": { "namespaces": { "bip122": { "address": "", "curve": "SECP256K1", "bitcoin": { "p2wpkh": { "mainnet": "bc1qpl54na90wlmdxj5z4wtz7cpp6p32dc9th54w0m", "testnet": "tb1qpl54na90wlmdxj5z4wtz7cpp6p32dc9tajwa5g" } } }, "solana": { "address": "9JCnYR5kiBYGf3Csz5hLWab53n7ie8o6HaUdeVBUvPRq", "curve": "ED25519" }, "stellar": { "address": "GB5ULMO2C3IWT5N5PFSBONB3WWRTNHKYCMJ53R2P4QXYRIVSVOJXBAST", "curve": "ED25519" }, "eip155": { "address": "0x8257a8212a0ce84de8ffb951a42bba2a2a9ffce8", "curve": "SECP256K1" }, "tron": { "address": "TMrPrVftxf1Yq8jwzrBER58LhfsiBZKhcX", "curve": "SECP256K1" } } } } ``` ## Receive testnet tokens Next, we'll fund your wallet with testnet tokens. We will use the [Client API Fund Wallet endpoint](./client/reference) for this to get some `MON` on Monad Testnet. If you need testnet tokens on other blockchains, we recommend checking out our [Testnet Faucets](../resources/testnet-faucets) doc. ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/fund \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "chainId": "eip155:10143", "token": "NATIVE", "amount": "0.01" }' ``` ## Send tokens Portal provides [several ways to sign and submit transactions](./enclave-mpc/guide/sign-ethereum-transactions), but the simplest is to use the [`/v1/assets/send`](./enclave-mpc/reference#send-assets) endpoint. ### EVM There are a few request body parameters you will need to provide for this request: * `share` * The `/v1/generate`'s response's `share` property for the `SECP256K1` object. * `chain` * The pretty chain (ie. `monad-testnet`) or the [CAIP-2 chain identifier](../resources/chain-id-formatting). * `token` * The token shorthand (ie. `MON` or `NATIVE`) or the token address of the token you want to send. * `to` * The destination address. * `amount` * The amount of the token you want to send with decimals applied. * `rpcUrl` * The RPC URL is the endpoint Portal uses to submit the transaction. Use Portal’s RPC URL to avoid signing up with a third-party provider. ```bash theme={null} curl -X POST 'https://mpc-client.portalhq.io/v1/assets/send' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer ' \ -d '{ "share": "", "chain": "monad-testnet", "token": "NATIVE", "to": "0xcae0d97d201ad54275b6e8a6b547c7611ad47963", "amount": "0.0001", "rpcUrl": "https://api.portalhq.io/rpc/v1/eip155/10143" }' ``` The response of this request will include the transaction hash. For standard (EOA) clients you can search the `transactionHash` on [testnet.monadexplorer.com](https://testnet.monadexplorer.com/) to view the transaction details. Account Abstraction clients receive a UserOperation hash instead, which won't resolve on the explorer — see the note below. ```json theme={null} { "transactionHash": "0x2ca7d87c9cb56965224faeeba22f15216ab480581db6902975cb0c494b58edf2", "metadata": { "amount": ".0001", "rawAmount": "100000000000000", "tokenDecimals": 18, "tokenSymbol": "MON" } } ``` **Account Abstraction clients:** the `transactionHash` returned here is a **UserOperation hash**, not an on-chain transaction hash — it will **not** resolve on a block explorer such as Etherscan or Monadscan. The on-chain transaction hash is only assigned once the bundler includes the UserOperation on-chain. Look up the UserOperation hash on a UserOp explorer such as [JiffyScan](https://jiffyscan.xyz/) to find the resulting transaction hash. See [Account abstraction](../resources/account-abstraction). ### Solana Next, let's send tokens using Solana. The request will be very similar to the EVM request we just made, except that we need to use the `ED25519` share from the generate response and `solana-devnet` as our chain. You will need **`SOL`** to submit a Solana transaction, which is not currently supported by **`portal.receiveTestnetAsset`**. You can find a faucet to get test **`SOL`** tokens [here](../resources/testnet-faucets). ```bash theme={null} curl -X POST 'https://mpc-client.portalhq.io/v1/assets/send' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer ' \ -d '{ "share": "", "chain": "solana-devnet", "token": "NATIVE", "to": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "amount": "0.0001" }' ``` The response of this request will include the transaction hash. You can search the `transactionHash` on [solscan.io?cluster=devnet](https://solscan.io/?cluster=devnet) to view the transaction details. ```json theme={null} { "transactionHash": "4TDjz6rnYhKRMmdBBZNBxfEP9AVbd5dDt9m3aEQTJm3cioDFqtnDo7ZcZbs5xQc3vwCf4eAg9vxi1kD46th4wKxq", "metadata": { "amount": "0.0001", "lastValidBlockHeight": "336343025", "rawAmount": "100000", "tokenDecimals": 9, "tokenSymbol": "SOL" } } ``` *** You just created a Portal client, created their wallet, accessed their addresses, received testnet tokens, and sent tokens on both Monad Testnet and Solana Devnet! 🎉 Next we recommend going through the complete [Enclave MPC API guide](./enclave-mpc/guide/create-a-wallet) where we go into much more detail. # API Reference Source: https://docs.portalhq.io/apis/reseller/api-reference-link Interactive API reference for the Reseller API endpoints. View the full interactive Reseller API reference. # Getting Started Source: https://docs.portalhq.io/apis/reseller/guide/getting-started Create and manage sub-custodians with the Reseller API. # Reseller API The Reseller API lets approved resellers create and manage sub-custodians through Portal. Use it to create a sub-custodian, view its environments, suspend or reactivate access, disable signing without suspending, create environment-scoped Portal API keys for that sub-custodian, and retrieve usage statistics. ## Authentication Create a Reseller API key in the Portal dashboard from the **Reseller** section. Reseller API keys are environment-agnostic and authenticate the reseller itself. Send the key as a bearer token on every request: ```bash theme={null} Authorization: Bearer ``` A reseller can only access sub-custodians that belong to that reseller. ## Create a sub-custodian Create a sub-custodian with an initial user. Portal creates Development and Production environments for the sub-custodian, enables Portal backup and Alert webhooks, and sends the initial user an onboarding invite. ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/resellers/custodians \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "custodian": { "name": "Acme Payments" }, "user": { "email": "admin@acmepayments.com", "firstName": "Avery", "lastName": "Stone" } }' ``` Sub-custodians created through the Reseller API are active, non-reseller custodians on the Start-Up plan with Portal backup and Alert webhooks enabled in both environments. ## List sub-custodians ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/resellers/custodians \ --header 'Authorization: Bearer ' ``` The response includes each sub-custodian's environments, users, billing plan, billing status, and Portal API key metadata. ## Get reseller statistics ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/resellers/stats \ --header 'Authorization: Bearer ' ``` The response includes all-time `walletsGenerated` and `signaturesCompleted` metrics. `totals` summarizes the reseller's usage across all sub-custodians, while `custodians` contains the metrics for each sub-custodian. Sub-custodians without activity are included with zero values. ## List sub-custodian environments Use the sub-custodian ID from the create or list response. ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/resellers/custodians//environments \ --header 'Authorization: Bearer ' ``` ## Create a sub-custodian Portal API key Sub-custodian Portal API keys are scoped to one sub-custodian environment. The raw API key value is only returned when the key is created. ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/resellers/custodians//environments//api-keys \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "name": "Production API key" }' ``` ## Suspend a sub-custodian Suspending a sub-custodian blocks that custodian's Portal dashboard users, Portal API keys, client API keys, client session tokens, and service-authenticated requests. Reactivating a suspended sub-custodian sets its billing status back to `ACTIVE`. ```bash theme={null} curl --request PUT \ --url https://api.portalhq.io/api/v3/resellers/custodians//suspend \ --header 'Authorization: Bearer ' ``` ## Activate a sub-custodian ```bash theme={null} curl --request PUT \ --url https://api.portalhq.io/api/v3/resellers/custodians//activate \ --header 'Authorization: Bearer ' ``` ## Disable signing for a sub-custodian Suspending blocks everything. If you only want a sub-custodian to stop transacting, disable the `SIGN` capability instead. Every signing request from that sub-custodian's clients is rejected across all of its environments, while its dashboard, Portal API keys and client authentication keep working, so clients can still back up, recover and eject their wallets. ```bash theme={null} curl --request PATCH \ --url https://api.portalhq.io/api/v3/resellers/custodians//capabilities \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "capability": "SIGN", "disabled": true }' ``` Send the same request with `"disabled": false` to re-enable signing. The current value is returned as `disabledCapabilities` on every sub-custodian object, including the list sub-custodians response. Only the reseller can change this setting; it is independent of any per-client capability the sub-custodian disables itself. # Introduction Source: https://docs.portalhq.io/index Build blockchain-enabled products rapidly with Portal's SDKs, REST APIs, and more. Portal empowers you to launch blockchain-enabled products with unmatched security, simplicity, and speed. In the navigation menu on the left, you'll find comprehensive guides for Portal's SDKs, REST APIs, and more. If you've already set up your account, dive in and start building in minutes using one of the quickstart guides below. Get started with Portal SDKs for mobile and web. Get started with the Enclave MPC API. If you haven't set up your account with Portal yet, request access now or [book an onboarding call with our team](https://www.portalhq.io/get-started) to learn more. Learn more and get started with Portal. ## Feedback We're always eager for your feedback to enhance the developer experience. Join our [Slack community](https://join.slack.com/t/portalcommunity/shared_invite/zt-1zlyexf43-A94Z4ugntrgLGs2j5slmeQ) and [follow us on X](https://x.com/portal_hq) to stay up-to-date with the latest news and updates. Join the community for support and discussions. Stay updated with the latest news. If you're already connected with us, feel free to reach out via Slack with any questions. # Overview Source: https://docs.portalhq.io/integrations/On-Off-Ramp/due Connect your Due account to Portal to move money between fiat and crypto with your Portal wallets. ## Overview Portal integrates with [Due](https://www.opendue.com/) so you can move money between fiat and crypto through Portal Client API endpoints, including global payins and payouts, virtual accounts, and FX. This page covers one-time setup in the Portal Dashboard and links to workflow guides. ## Steps to enable the Portal Due integration ### 1. Get your Due API keys * Request API access from Due at [opendue.com/api](https://www.opendue.com/api) for the environments you plan to use (sandbox and production keys are issued separately). Keep your Due API keys secure. You will configure them in Portal in the next step, and Portal will proxy requests server-side. ### 2. Enable the Due integration in Portal 1. Open the [Portal Dashboard](https://app.portalhq.io). 2. Navigate to the `Integrations` page. 3. Locate `Due` and add the integration. 4. Paste your Due API key for the selected Portal environment. 5. Optionally set the application fees you charge on top of Due's fees. End-user clients can't override them. See [Application fees](/integrations/On-Off-Ramp/due-fees). 6. Save. Portal Dashboard Due configuration modal showing the API key input and application fees panel Integrations are environment-scoped. Configure Due separately for each environment (for example Development and Production), using the matching Due sandbox or production API key. ### 3. Configure webhooks Due webhook endpoints are managed through the Portal Custodian API rather than a provider dashboard. See [Webhooks](/integrations/On-Off-Ramp/due-webhooks). ## Supported rails and currencies Due's coverage is channel-driven and evolves over time. Rather than hard-coding a list, query the channels endpoint at runtime to populate dropdowns and validate selections: * Channels: `GET /clients/me/integrations/due/channels` (pass `onlyAvailable=true` to filter to channels the customer can use today) For onchain recipients, Portal accepts a CAIP-2 `chainId` and maps it to the Due recipient schema, defaulting the address to the client's Portal wallet when none is provided: | CAIP-2 namespace | Due schema | | ---------------- | ---------- | | `eip155:*` | `evm` | | `solana:*` | `solana` | | `tron:*` | `tron` | See [Payins](/integrations/On-Off-Ramp/due-payins) for end-to-end examples. ## Workflow guides * [KYC onboarding](/integrations/On-Off-Ramp/due-kyc) * [Payins](/integrations/On-Off-Ramp/due-payins) * [Payouts](/integrations/On-Off-Ramp/due-payouts) * [Virtual accounts](/integrations/On-Off-Ramp/due-virtual-accounts) * [Application fees](/integrations/On-Off-Ramp/due-fees) * [Webhooks](/integrations/On-Off-Ramp/due-webhooks) ## API reference Open the interactive Due endpoints reference. If you are implementing payin and payout flows, start with KYC first. Transfer endpoints require a provisioned Due customer with approved KYC. # Application fees Source: https://docs.portalhq.io/integrations/On-Off-Ramp/due-fees Charge your own fee on top of Due fees. Portal enforces it server-side so clients cannot override it. ## Overview An application fee is your markup on top of Due's own fees, charged on every transfer. You configure it once per environment in the Portal Dashboard, and Portal applies it to every quote, transfer, and virtual account. Your clients see the fee in their quotes but cannot change it. ## How enforcement works Portal enforces fees server-side. On every quote, transfer, and virtual-account update, Portal strips any `applicationFeeBps` or `applicationFeeAmount` a client sends and injects the fee you configured. A client can include those fields in a request, but they are always ignored in favor of your configuration. The fee is applied to a single side of each transfer (the side you choose), not to both sides. ## Configure fees in the Portal Dashboard 1. Open the [Portal Dashboard](https://app.portalhq.io). 2. Navigate to the `Integrations` page and open your `Due` integration. 3. Under `Application fees`, set your fee: * `Fee`: the percentage you add on top of Due's fees. * `Flat fee`: an optional fixed amount added per transfer. * `Charge on`: whether the fee comes from the amount sent (`Source`) or the amount received (`Destination`). * `Different fees per direction`: enable this to set separate fees for pay-ins (fiat to crypto) and pay-outs (crypto to fiat). Anything left blank inherits the default. 4. Save. Portal Dashboard Due configuration modal showing the API key input and application fees panel Fees are environment-scoped, like the API key. Configure them separately for each environment (for example Development and Production). ## The fee model * **Percentage and flat**: set either or both. The percentage applies to the charged side's amount; the flat fee is added on top. * **Default and per-direction**: the default fee applies to every transfer. Optional pay-in and pay-out overrides take precedence for their direction and fall back to the default when left blank. Direction is derived from the transfer: fiat to crypto is a pay-in, crypto to fiat is a pay-out. Virtual account deposits use the pay-in fee. * **Charge on**: `Source` takes the fee from the amount the customer sends; `Destination` takes it from the amount they receive. * **Subsidies**: a negative percentage subsidizes the customer instead of charging them. Due caps the subsidy at its own fee. ## How fees appear to clients Clients see the fee in the quote response, per side, alongside Due's own fees. They cannot alter it. ```json theme={null} { "data": { "token": "qte_abc", "source": { "rail": "ach", "currency": "USD", "amount": "100", "fee": "0.50", "applicationFee": "0.50", "totalFee": "1.00" }, "destination": { "rail": "base-sepolia", "currency": "USDC", "amount": "99.00", "fee": "0", "totalFee": "0" } } } ``` ## Next steps * [Payins](/integrations/On-Off-Ramp/due-payins) * [Payouts](/integrations/On-Off-Ramp/due-payouts) * [Virtual accounts](/integrations/On-Off-Ramp/due-virtual-accounts) # KYC onboarding Source: https://docs.portalhq.io/integrations/On-Off-Ramp/due-kyc Provision a Due customer, complete KYC, and unlock channels with endorsements. ## Overview Onboard a client before running payin or payout flows: provision the Due customer, complete KYC (hosted or API-driven), request endorsements for the channels you need, and accept terms of service when required. Portal proxies these endpoints to Due and returns Due's responses verbatim. Example responses below are illustrative; exact fields come from Due and may evolve. ## 1. Provision the Due customer Call `POST /customers` to create the Due customer for the authenticated client. ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/customers \ --header 'Authorization: Bearer [token]' \ --header 'Content-Type: application/json' \ --data '{ "type": "individual", "name": "Jane Doe", "email": "jane@example.com", "country": "US", "category": "self_employed", "kycReturnUrl": "https://example.com/due/return" }' ``` * `type` is `individual` or `business`. * `kycReturnUrl` is optional and controls where the user lands after a hosted KYC session. This endpoint is idempotent: calling it again for the same client returns the existing Due customer instead of creating a new one. Use `GET /customers` to fetch the customer and its status at any time. ## 2. Check KYC state and requirements `GET /kyc` returns the customer's KYC state and outstanding requirements. Drive your onboarding UI from this response. ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/kyc \ --header 'Authorization: Bearer [token]' ``` ## 3. Complete KYC Due supports two paths. The hosted session is the fastest way to get started; the API-driven flow gives you full control of the UI. ### Option A: hosted KYC session (recommended) Call `POST /kyc/session` to create a hosted session, then redirect the user to the returned URL. ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/kyc/session \ --header 'Authorization: Bearer [token]' ``` After the user finishes, they are sent to the `kycReturnUrl` you provided at provisioning. Subscribe to [webhooks](/integrations/On-Off-Ramp/due-webhooks) to detect approval. `GET /kyc` returns the current state whenever you need to read it directly. ### Option B: API-driven KYC (build your own UI) #### 1. Start a submission ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/kyc \ --header 'Authorization: Bearer [token]' ``` #### 2. Read the submission's required fields ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/kyc/submissions/{submissionId} \ --header 'Authorization: Bearer [token]' ``` #### 3. Submit collected information `POST /kyc/submissions/{submissionId}/info` accepts the form values as key-value pairs. Due validates them and returns field-level errors for anything missing or malformed. ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/kyc/submissions/{submissionId}/info \ --header 'Authorization: Bearer [token]' \ --header 'Content-Type: application/json' \ --data '{ "firstName": "Jane", "lastName": "Doe", "dateOfBirth": "1990-01-31" }' ``` #### 4. Upload documents First create the document against the submission to receive an upload token: ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/kyc/submissions/{submissionId}/documents \ --header 'Authorization: Bearer [token]' \ --header 'Content-Type: application/json' \ --data '{ "kind": "passport" }' ``` Example response: ```json theme={null} { "data": { "token": "doc_upload_token" } } ``` Then upload the file bytes with that token: ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/kyc/submissions/documents/{token} \ --header 'Authorization: Bearer [token]' \ --header 'Content-Type: application/json' \ --data '{ "dataBase64": "[base64-encoded file]", "filename": "passport.jpg" }' ``` * `dataBase64` is required. The decoded file can be up to 10MB. * The MIME type is inferred from the `filename` extension (`jpg`, `jpeg`, `png`, `webp`, `heic`, `pdf`), or pass `contentType` explicitly. #### 5. Complete the submission ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/kyc/submissions/{submissionId}/complete \ --header 'Authorization: Bearer [token]' ``` #### 6. Detect the outcome Subscribe to [webhooks](/integrations/On-Off-Ramp/due-webhooks) to learn when the customer is approved. `GET /kyc` returns the current state whenever you need to read it directly. ## 4. Endorsements Some channels require channel-specific approvals on top of base KYC. Each channel from `GET /channels` lists its `endorsementsRequired`. List the customer's endorsements: ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/kyc/endorsements \ --header 'Authorization: Bearer [token]' ``` Request an endorsement by code: ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/kyc/endorsements/{code} \ --header 'Authorization: Bearer [token]' ``` Requesting an endorsement that already exists returns a `409`. Treat it as already in progress and fetch its state instead. Check an endorsement's state and any extra requirements it adds: ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/kyc/endorsements/{code} \ --header 'Authorization: Bearer [token]' ``` If an endorsement adds requirements, they show up in `GET /kyc`; collect them through the same submission flow above. ## 5. Terms of service Some requirements include a terms of service token. Fetch the document and record the user's acceptance: ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/tos/{token} \ --header 'Authorization: Bearer [token]' ``` ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/tos/{token} \ --header 'Authorization: Bearer [token]' \ --header 'Content-Type: application/json' \ --data '{ "ipAddress": "203.0.113.10" }' ``` `ipAddress` is required and should be the end user's IP address, not your server's. Transfer, recipient, and virtual account endpoints require a provisioned Due customer with approved KYC. Some channels additionally require endorsements; check `endorsementsRequired` on the channel before initiating a transfer. ## Next steps * [Payins](/integrations/On-Off-Ramp/due-payins) * [Payouts](/integrations/On-Off-Ramp/due-payouts) * [Webhooks](/integrations/On-Off-Ramp/due-webhooks) # Payins Source: https://docs.portalhq.io/integrations/On-Off-Ramp/due-payins Move fiat into your client's Portal wallet with Due payins: quote a transfer, fund it, and settle onchain. ## Overview A Due payin moves fiat into your client's Portal wallet: the client sends a bank transfer, and Due settles the equivalent crypto onchain. Unlike a single-call payin, Due uses a quote-then-transfer model: discover a channel, create the crypto recipient, quote the transfer, create it, then show the client the funding instructions. Portal proxies these endpoints to Due and returns Due's responses verbatim. Example responses below are illustrative; exact fields come from Due and may evolve. For a persistent bank account that auto-converts every deposit (rather than one transfer at a time), see [Virtual accounts](/integrations/On-Off-Ramp/due-virtual-accounts). ## 1. Prerequisite: approved KYC Transfers require a provisioned Due customer with approved KYC. Some channels additionally require endorsements. See [KYC onboarding](/integrations/On-Off-Ramp/due-kyc). ## 2. Discover a channel `GET /channels` lists the rails and currencies available to the customer. Pass `onlyAvailable=true` to filter to channels the customer can use today. Pick a fiat source rail (for example `ach` or `sepa`) and a crypto destination rail and currency (for example `base-sepolia` and `USDC`). ```bash theme={null} curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/due/channels?onlyAvailable=true' \ --header 'Authorization: Bearer [token]' ``` ## 3. Create the destination recipient The recipient is where the crypto settles: your client's Portal wallet. Call `POST /recipients`. Portal maps a CAIP-2 `chainId` to the Due recipient schema and defaults the address to the client's Portal wallet, so you can omit the address entirely. ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/recipients \ --header 'Authorization: Bearer [token]' \ --header 'Content-Type: application/json' \ --header 'Idempotency-Key: [unique-key]' \ --data '{ "name": "Jane Doe", "chainId": "eip155:8453", "details": {} }' ``` * Pass a CAIP-2 `chainId` and leave `details.address` unset to default both the schema and the address to the client's Portal wallet. Supported namespaces map as `eip155:* -> evm`, `solana:* -> solana`, `tron:* -> tron`. * To send to an address the client does not custody in Portal, set an explicit `details.address` and `isExternal: true`. External recipients are never defaulted. * `details.address` is the chain's native address string: `0x`-hex for EVM, base58 for Solana (for example `Bxi9R9ckm4qQZdha85LGY66xyhoo2eaAzfsSpjQJQJ4G`), base58check for Tron. * The `Idempotency-Key` header is optional and deduplicates retries. Example response: ```json theme={null} { "data": { "id": "rcp_123", "isActive": true, "isValid": true } } ``` ## 4. Quote the transfer `POST /transfers/quote` prices the transfer. `source` is the fiat side and `destination` is the crypto side. Set the destination `amount` to `"0"` to let Due compute it from the source amount. ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/transfers/quote \ --header 'Authorization: Bearer [token]' \ --header 'Content-Type: application/json' \ --data '{ "source": { "rail": "ach", "currency": "USD", "amount": "100" }, "destination": { "rail": "base-sepolia", "currency": "USDC", "amount": "0" } }' ``` Application fees are enforced by the custodian's Portal Dashboard configuration and injected server-side. Any `applicationFeeBps` or `applicationFeeAmount` you send is stripped, so clients cannot override them. See [Application fees](/integrations/On-Off-Ramp/due-fees). Example response: ```json theme={null} { "data": { "token": "qte_abc", "fxRate": 1, "fxMarkup": 0, "expiresAt": "2026-07-06T18:05:00Z", "source": { "rail": "ach", "currency": "USD", "amount": "100", "fee": "0.50", "totalFee": "0.50" }, "destination": { "rail": "base-sepolia", "currency": "USDC", "amount": "99.50", "fee": "0", "totalFee": "0" } } } ``` ## 5. Create the transfer `POST /transfers` turns a quote into a transfer. Pass the quote `token` and the recipient `id`. ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/transfers \ --header 'Authorization: Bearer [token]' \ --header 'Content-Type: application/json' \ --header 'Idempotency-Key: [unique-key]' \ --data '{ "quote": "qte_abc", "recipient": "rcp_123" }' ``` * `memo` and `purposeCode` are optional and forwarded to Due. * The `Idempotency-Key` header is optional and deduplicates retries. Example response: ```json theme={null} { "data": { "id": "trf_789", "status": "awaiting_funds", "expiresAt": "2026-07-06T18:10:00Z" } } ``` ## 6. Get funding instructions While the transfer is `awaiting_funds`, call `POST /transfers/{transferId}/funding-address` to get the fiat payment details. Show `details` to your client so they can send the bank transfer. ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/transfers/{transferId}/funding-address \ --header 'Authorization: Bearer [token]' ``` Example response: ```json theme={null} { "data": { "kind": "bank_account", "details": { "accountHolderName": "Due Payments", "accountNumber": "900366164700", "routingNumber": "101019644", "bankName": "Lead Bank", "reference": "trf_789" } } } ``` ## 7. Track settlement After the client sends funds, subscribe to [webhooks](/integrations/On-Off-Ramp/due-webhooks) to track settlement. The status progresses `awaiting_funds` -> `funds_received` -> `approved` -> `payment_submitted` -> `payment_processed`. `GET /transfers/{transferId}` returns a transfer's current state whenever you need to read it directly. ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/transfers/{transferId} \ --header 'Authorization: Bearer [token]' ``` Acknowledge webhook deliveries quickly with a `2XX`, then process events asynchronously. ## Next steps * [Payouts](/integrations/On-Off-Ramp/due-payouts) * [Virtual accounts](/integrations/On-Off-Ramp/due-virtual-accounts) * [Webhooks](/integrations/On-Off-Ramp/due-webhooks) # Payouts Source: https://docs.portalhq.io/integrations/On-Off-Ramp/due-payouts Move crypto out of your client's Portal wallet with Due payouts: quote a transfer, deposit onchain, and settle to a bank account. ## Overview A Due payout is the mirror of a payin: crypto leaves your client's Portal wallet and Due settles the equivalent fiat to a bank beneficiary. You discover a channel, create the fiat recipient, quote the transfer, create it, then send crypto to the onchain deposit address Due returns. Portal proxies these endpoints to Due and returns Due's responses verbatim. Example responses below are illustrative; exact fields come from Due and may evolve. ## 1. Prerequisite: approved KYC Transfers require a provisioned Due customer with approved KYC. Some channels additionally require endorsements. See [KYC onboarding](/integrations/On-Off-Ramp/due-kyc). ## 2. Discover a channel `GET /channels` lists the rails and currencies available to the customer. Pass `onlyAvailable=true` to filter to channels the customer can use today. Pick a crypto source rail (for example `base-sepolia` / `USDC`) and a fiat destination rail and currency (for example `sepa` / `EUR`). Each channel also exposes `purposeCodes` and `memoConfig`, which you will need when creating the transfer. ```bash theme={null} curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/due/channels?onlyAvailable=true' \ --header 'Authorization: Bearer [token]' ``` ## 3. Create the fiat recipient The recipient is the bank beneficiary that receives the fiat. Call `POST /recipients` with `isExternal: true`. Unlike a payin recipient, this is a bank account, not a wallet, so the CAIP-2 `chainId` defaulting does not apply. ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/recipients \ --header 'Authorization: Bearer [token]' \ --header 'Content-Type: application/json' \ --header 'Idempotency-Key: [unique-key]' \ --data '{ "name": "Jane Doe", "isExternal": true, "details": { "schema": "bank_sepa", "iban": "DE89370400440532013000", "accountType": "individual" } }' ``` * Set `isExternal: true` so Portal forwards the recipient to Due without attempting any Portal-wallet address defaulting. * The exact `details` fields depend on the channel's bank schema (for example `bank_sepa` or `bank_us`): account or IBAN, beneficiary address, and entity type. Due validates them and returns field-level errors for anything missing. * The `Idempotency-Key` header is optional and deduplicates retries. Example response: ```json theme={null} { "data": { "id": "rcp_456", "isExternal": true, "isValid": true } } ``` ## 4. Quote the transfer `POST /transfers/quote` prices the transfer. `source` is the crypto side and `destination` is the fiat side. Set the destination `amount` to `"0"` to let Due compute the fiat the beneficiary receives from the crypto amount sent. ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/transfers/quote \ --header 'Authorization: Bearer [token]' \ --header 'Content-Type: application/json' \ --data '{ "source": { "rail": "base-sepolia", "currency": "USDC", "amount": "100" }, "destination": { "rail": "sepa", "currency": "EUR", "amount": "0" } }' ``` Application fees are enforced by the custodian's Portal Dashboard configuration and injected server-side. Any `applicationFeeBps` or `applicationFeeAmount` you send is stripped, so clients cannot override them. See [Application fees](/integrations/On-Off-Ramp/due-fees). Example response: ```json theme={null} { "data": { "token": "qte_def", "fxRate": 0.92, "fxMarkup": 0, "expiresAt": "2026-07-06T18:05:00Z", "source": { "rail": "base-sepolia", "currency": "USDC", "amount": "100", "fee": "0.50", "totalFee": "0.50" }, "destination": { "rail": "sepa", "currency": "EUR", "amount": "91.54", "fee": "0", "totalFee": "0" } } } ``` ## 5. Create the transfer `POST /transfers` turns a quote into a transfer. Pass the quote `token` and the recipient `id`. ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/transfers \ --header 'Authorization: Bearer [token]' \ --header 'Content-Type: application/json' \ --header 'Idempotency-Key: [unique-key]' \ --data '{ "quote": "qte_def", "recipient": "rcp_456", "purposeCode": "GDDS" }' ``` * `memo` and `purposeCode` are optional. Whether they are required, and which values are valid, comes from the channel's `memoConfig` and `purposeCodes` from step 2. * The `Idempotency-Key` header is optional and deduplicates retries. Example response: ```json theme={null} { "data": { "id": "trf_abc", "status": "awaiting_funds", "expiresAt": "2026-07-06T18:10:00Z" } } ``` ## 6. Get the deposit address While the transfer is `awaiting_funds`, call `POST /transfers/{transferId}/funding-address` to get the onchain address to send the source crypto to. ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/transfers/{transferId}/funding-address \ --header 'Authorization: Bearer [token]' ``` Example response: ```json theme={null} { "data": { "kind": "onchain_address", "details": { "address": "0xc118ad00663be7d0360b56a4e9fbb20530bf4693", "network": "base-sepolia" } } } ``` For some rails Due returns a `kind` of `external_action`, meaning the deposit needs an onchain signature rather than a plain transfer. Read `details` for the specific action. ## 7. Send crypto and track settlement Send the source crypto from your client's Portal wallet to the deposit `details.address` (for example with the SDK's `sendAsset`). Once the deposit lands, subscribe to [webhooks](/integrations/On-Off-Ramp/due-webhooks) to track settlement. The status progresses `awaiting_funds` -> `funds_received` -> `approved` -> `payment_submitted` -> `payment_processed`. `GET /transfers/{transferId}` returns a transfer's current state whenever you need to read it directly. ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/transfers/{transferId} \ --header 'Authorization: Bearer [token]' ``` Acknowledge webhook deliveries quickly with a `2XX`, then process events asynchronously. ## Next steps * [Payins](/integrations/On-Off-Ramp/due-payins) * [Virtual accounts](/integrations/On-Off-Ramp/due-virtual-accounts) * [Webhooks](/integrations/On-Off-Ramp/due-webhooks) # Virtual accounts Source: https://docs.portalhq.io/integrations/On-Off-Ramp/due-virtual-accounts Give your client a persistent bank account that auto-converts every deposit to crypto in their Portal wallet. ## Overview A virtual account is a persistent fiat bank account tied to your client's Portal wallet. Every deposit auto-converts to crypto and settles onchain, with no per-deposit API call. Use it as a standing on-ramp, in contrast to a one-off [payin](/integrations/On-Off-Ramp/due-payins) that quotes and settles a single transfer. Portal proxies these endpoints to Due and returns Due's responses verbatim. Example responses below are illustrative; exact fields come from Due and may evolve. ## 1. Prerequisite: approved KYC Virtual accounts require a provisioned Due customer with approved KYC. Some channels additionally require endorsements. See [KYC onboarding](/integrations/On-Off-Ramp/due-kyc). ## 2. Discover a channel `GET /channels` lists the rails and currencies available to the customer. Pass `onlyAvailable=true` to filter to channels the customer can use today. Pick the fiat deposit channel (the source, for example `sepa` / `EUR`) and the crypto destination rail and currency (for example `base-sepolia` / `USDC`). ```bash theme={null} curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/due/channels?onlyAvailable=true' \ --header 'Authorization: Bearer [token]' ``` ## 3. Register the Portal wallet The virtual account settles crypto to a wallet Due knows about, so register your client's Portal wallet first. Call `POST /wallets` with the wallet address. ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/wallets \ --header 'Authorization: Bearer [token]' \ --header 'Content-Type: application/json' \ --data '{ "address": "0xc118ad00663be7d0360b56a4e9fbb20530bf4693" }' ``` Example response: ```json theme={null} { "data": { "id": "wlt_123", "address": "evm:0xc118ad00663be7d0360b56a4e9fbb20530bf4693", "preferences": { "selectedNetworks": ["base-sepolia"] } } } ``` The wallet is usable as a destination only once Due binds it to networks, shown as a non-empty `preferences.selectedNetworks`. Use `GET /wallets` to list registered wallets and confirm. The returned `id` (`wlt_...`) is the virtual account `destination` in the next step. ## 4. Create the virtual account Call `POST /virtual-accounts`. The fiat side is the deposit channel (`schemaIn`, `currencyIn`) and the crypto side is where funds settle (`railOut`, `currencyOut`). ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/virtual-accounts \ --header 'Authorization: Bearer [token]' \ --header 'Content-Type: application/json' \ --data '{ "destination": "wlt_123", "schemaIn": "bank_sepa", "currencyIn": "EUR", "railOut": "base-sepolia", "currencyOut": "USDC", "reference": "customer-1234" }' ``` * `destination`: the `wlt_...` id from step 3. Converted crypto settles here. * `schemaIn`: the fiat deposit bank schema (for example `bank_sepa` or `bank_us`). * `currencyIn`: the fiat currency accepted for deposits (for example `EUR`). Optional; if omitted, Due resolves a default and the returned `key` includes the resolved currency, so always use the returned `key` for later reads and updates. * `railOut` / `currencyOut`: the crypto rail and asset funds convert to. * `reference`: your own label for the account, also used to build its key. Example response: ```json theme={null} { "data": { "id": "va_789", "key": "wlt_123/bank_sepa/EUR/base-sepolia/USDC/customer-1234", "isActive": false, "details": null } } ``` ## 5. Wait for the account to activate Due provisions the underlying bank account asynchronously (up to about a minute). Subscribe to [webhooks](/integrations/On-Off-Ramp/due-webhooks) to learn when it activates. You can also read the account by its composite key at any time; it is ready once `isActive` is `true` and `details` is populated. The key is `destination/schemaIn/currencyIn/railOut/currencyOut/reference`. ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/clients/me/integrations/due/virtual-accounts/key/wlt_123/bank_sepa/EUR/base-sepolia/USDC/customer-1234 \ --header 'Authorization: Bearer [token]' ``` Example response once active: ```json theme={null} { "data": { "id": "va_789", "isActive": true, "details": { "bankName": "Lead Bank", "beneficiary": "Due Payments", "iban": "DE89370400440532013000", "bic": "DEUTDEFF", "reference": "customer-1234" } } } ``` Webhooks also notify you when deposits arrive and convert, so you never have to check the account yourself. Acknowledge deliveries quickly with a `2XX`, then process events asynchronously. ## 6. Fund the account Show the `details` bank information to your client. Any deposit to that account auto-converts to `currencyOut` and settles in their Portal wallet. There is no per-deposit API call. ## Managing the account * `GET /virtual-accounts/list` lists the customer's virtual accounts. * `POST /virtual-accounts/key/{key}` updates an account by its composite key. Application fees are enforced by the custodian's Portal Dashboard configuration and injected server-side. Any `applicationFeeBps` or `applicationFeeAmount` you send is stripped, so clients cannot override them. See [Application fees](/integrations/On-Off-Ramp/due-fees). ## Next steps * [Payins](/integrations/On-Off-Ramp/due-payins) * [Payouts](/integrations/On-Off-Ramp/due-payouts) * [Webhooks](/integrations/On-Off-Ramp/due-webhooks) # Webhooks Source: https://docs.portalhq.io/integrations/On-Off-Ramp/due-webhooks Register Due webhook endpoints through the Portal Custodian API and verify deliveries with the endpoint Ed25519 key. ## Overview Due delivers lifecycle events (KYC, transfers, virtual accounts) to a URL you register. Webhooks are the recommended way to track status changes rather than checking state yourself. Unlike some integrations, Due webhook endpoints are managed through the Portal Custodian API, not a provider dashboard and not the Client API. You register a URL, subscribe to events, and verify each delivery with the endpoint's Ed25519 public key. These calls use a Custodian API key (`Authorization: Bearer [custodian-api-key]`), not a client session token. Endpoints are environment-scoped: register them separately for each environment (for example Development and Production). The shorthand paths below are relative to `/api/v3/custodians/me/integrations/due`. ## 1. List available events `GET /webhooks/events` returns the event types you can subscribe to. ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/custodians/me/integrations/due/webhooks/events \ --header 'Authorization: Bearer [custodian-api-key]' ``` ## 2. Create a webhook endpoint `POST /webhooks` registers your URL. Pass the events you want in `events`, or omit it to receive all of them. ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/custodians/me/integrations/due/webhooks \ --header 'Authorization: Bearer [custodian-api-key]' \ --header 'Content-Type: application/json' \ --header 'Idempotency-Key: [unique-key]' \ --data '{ "url": "https://example.com/webhooks/due", "description": "Production events", "events": ["transfer.status_changed", "virtual_account.updated", "bp.kyc.status_changed"] }' ``` Example response: ```json theme={null} { "data": { "id": "whe_123", "url": "https://example.com/webhooks/due", "subscribedEvents": ["transfer.status_changed", "virtual_account.updated", "bp.kyc.status_changed"], "enabled": true, "publicKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA...\n-----END PUBLIC KEY-----\n" } } ``` Store the `publicKey`. It is the Ed25519 key you use to verify every delivery from this endpoint, and it is returned only in the endpoint's create and list responses. ## 3. Events to subscribe to | Event | Fires when | | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `transfer.created` | A transfer is created. | | `transfer.status_changed` | A transfer moves through `awaiting_funds`, `funds_received`, `approved`, `payment_submitted`, `payment_processed` (and failure or refund states). | | `virtual_account.created` | A virtual account is created. | | `virtual_account.updated` | A virtual account changes state, including when it activates. | | `bp.kyc.status_changed` | The customer's KYC status changes. | | `transfers.kyc.submission.status_changed` | A KYC submission changes state (provider-specific `sumsub` and `bridge` variants also exist). | | `bp.tos_accepted` | The customer accepts a terms of service document. | `transfer.status_changed` tracks the same statuses shown in the [payins](/integrations/On-Off-Ramp/due-payins) and [payouts](/integrations/On-Off-Ramp/due-payouts) guides, and `virtual_account.updated` tells you when a [virtual account](/integrations/On-Off-Ramp/due-virtual-accounts) becomes active. ## 4. Receive and verify deliveries Due sends a `POST` to your URL for each event, with the signature hex-encoded in the `X-Webhook-Signature` header. Verify it against the raw request body using the endpoint's Ed25519 `publicKey` before trusting the payload. ```javascript theme={null} import crypto from 'node:crypto' function verifyDueWebhook(rawBody, signatureHex, publicKeyPem) { return crypto.verify( null, Buffer.from(rawBody), publicKeyPem, Buffer.from(signatureHex, 'hex'), ) } // Express example. Capture the raw body (e.g. express.raw()) so the bytes match what Due signed. app.post('/webhooks/due', express.raw({ type: '*/*' }), (req, res) => { const signature = req.header('X-Webhook-Signature') if (!signature || !verifyDueWebhook(req.body, signature, DUE_ENDPOINT_PUBLIC_KEY)) { return res.sendStatus(401) } res.sendStatus(200) const event = JSON.parse(req.body.toString('utf8')) processAsync(event) }) ``` Verify the signature over the raw, unparsed body. Reframing the JSON changes the bytes and breaks verification. Reject any delivery that fails verification. Acknowledge deliveries quickly with a `2XX`, then process events asynchronously so a slow handler does not cause Due to retry. ## 5. Manage endpoints * `GET /webhooks` lists your registered endpoints. * `POST /webhooks/{webhookId}` updates one; send any of `url`, `description`, `events`, or `enabled` (set `enabled: false` to pause deliveries without deleting). * `DELETE /webhooks/{webhookId}` removes one. ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/custodians/me/integrations/due/webhooks/whe_123 \ --header 'Authorization: Bearer [custodian-api-key]' \ --header 'Content-Type: application/json' \ --data '{ "enabled": false }' ``` ## 6. Inspect and retry deliveries `GET /webhooks/{webhookId}/events` returns the delivery history for an endpoint, including `attempts`, `responseStatusCode`, and `lastError`. Retry a specific event with `POST /webhooks/{webhookId}/events/{eventId}/retry`. ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/custodians/me/integrations/due/webhooks/whe_123/events \ --header 'Authorization: Bearer [custodian-api-key]' ``` The response is paginated: when more events exist, `data.next` holds the cursor for the next page. Portal forwards your query parameters to Due, so pass that value back as Due's pagination cursor on the following request to page through the history. In the event history, `eventData` is the event payload as a byte array. Decode it to JSON before reading, for example `JSON.parse(Buffer.from(eventData).toString('utf8'))`. ## Next steps * [KYC onboarding](/integrations/On-Off-Ramp/due-kyc) * [Payins](/integrations/On-Off-Ramp/due-payins) * [Payouts](/integrations/On-Off-Ramp/due-payouts) * [Virtual accounts](/integrations/On-Off-Ramp/due-virtual-accounts) # Overview Source: https://docs.portalhq.io/integrations/On-Off-Ramp/meld Connect your Meld account to Portal to embed the Meld White-Label API for buy and sell crypto through aggregated providers. ## Overview Portal integrates with [Meld](https://meld.io/) so you can offer buy, sell, and transfer crypto experiences via Meld's [White-Label API](https://docs.meld.io/docs/whitelabel-api-guide) — an aggregated network of fiat-on/off-ramp providers exposed as a single embeddable session. This page covers one-time setup in the Portal Dashboard and links to the White-Label API workflow guide. ## Steps to enable the Portal Meld integration ### 1. Create your Meld credentials * Sign up for a Meld account at [dashboard.meld.io](https://dashboard.meld.io/#/sign-up). * From the Meld Dashboard, generate API keys for the environments you plan to use (sandbox and production are issued separately). Keep your Meld API keys secure. You will configure them in Portal in the next step. ### 2. Enable the Meld integration in Portal 1. Open the [Portal Dashboard](https://app.portalhq.io). 2. Navigate to the `Integrations` page. 3. Locate `Meld` and configure your integration. 4. Paste your Meld API key for the selected Portal environment. 5. Save. Portal Dashboard Meld configuration modal showing the API Key input Integrations are environment-scoped. Configure Meld separately for each environment (for example Development and Production), using the matching Meld sandbox or production API key. ### 3. Configure webhooks (optional) Webhooks are configured directly in Meld Dashboard. See [Webhooks](/integrations/On-Off-Ramp/meld-webhooks). ## Supported countries, currencies, and assets Meld's coverage spans dozens of countries, fiat currencies, crypto assets, and chains, and the catalog evolves with provider availability. Rather than hard-coding a list, query the discovery endpoints at runtime to populate dropdowns and validate selections: * Countries — `GET /clients/me/integrations/meld/discovery/countries` * Fiat currencies — `GET /clients/me/integrations/meld/discovery/fiat-currencies` * Crypto currencies — `GET /clients/me/integrations/meld/discovery/crypto-currencies` * Payment methods — `GET /clients/me/integrations/meld/discovery/payment-methods` * Service providers — `GET /clients/me/integrations/meld/discovery/service-providers` See the [White-Label API guide](/integrations/On-Off-Ramp/meld-widget#step-1-discovery) for end-to-end discovery examples. ## Workflow guide * [White-Label API](/integrations/On-Off-Ramp/meld-widget) * [Webhooks](/integrations/On-Off-Ramp/meld-webhooks) ## API reference Open the interactive Meld endpoints reference. Cache the discovery responses client-side — countries, currencies, and payment methods change rarely and re-querying on every page load adds unnecessary latency. # Webhooks Source: https://docs.portalhq.io/integrations/On-Off-Ramp/meld-webhooks Configure Meld Dashboard webhooks to receive transaction and customer lifecycle events. ## Overview Configure webhooks directly in [Meld Dashboard](https://dashboard.meld.io/). For event types and payload shapes, see [Meld's webhook events](https://docs.meld.io/docs/webhook-events). For signature verification, see [Meld's webhook authentication guide](https://docs.meld.io/docs/webhooks-authentication). ## 1. Add your endpoint in Meld Dashboard In [Meld Dashboard](https://dashboard.meld.io/) (sandbox or production): * Go to **Developer → Webhooks → Add endpoint**. * Enter your HTTPS URL and subscribe to the events you care about (transaction lifecycle, KYC, etc.). * Save — Meld generates a signing secret for the endpoint that you'll need in the next step. ## 2. Verify webhook signatures Meld signs every delivery with the endpoint's secret. Copy the secret from the endpoint you just created, store it securely, and verify the signature on every inbound request — see [Meld's webhook authentication guide](https://docs.meld.io/docs/webhooks-authentication) for the formula and sample code. ## 3. Verify delivery * Send a test event from Meld Dashboard. * Confirm your endpoint returns `2XX`. * Process events asynchronously after acknowledgment. Acknowledge webhook deliveries quickly with `2XX`, then process events asynchronously after signature verification. # White-Label API Source: https://docs.portalhq.io/integrations/On-Off-Ramp/meld-widget Embed the Meld White-Label API for buy, sell, and transfer crypto end-to-end through Portal Client API endpoints. ## Overview Meld's [White-Label API](https://docs.meld.io/docs/whitelabel-api-guide) is an iframe-based experience for buy, sell, and transfer crypto sessions powered by an aggregated network of providers. Portal exposes thin proxy endpoints under `/clients/me/integrations/meld/*`. The minimum end-to-end sequence is: 1. **Discover** supported countries, currencies, and payment methods. 2. **Quote** the conversion to confirm pricing and pick a service provider. 3. **Create a widget session** — Meld returns a `widgetUrl` you embed in an iframe or load via redirect. 4. **Track transaction status** by session or transaction ID (or via Meld webhooks configured in Meld Dashboard). The widget itself collects user identity and drives KYC inside the iframe. You can optionally [pre-create a Meld customer](#optional-pre-create-a-meld-customer) for your Portal client to speed up KYC or to track sessions against a persistent customer record — but it is not required. For the widget UX itself — iframe sizing, redirect handling, supported lock fields — see [Meld's Provider UI Launch Implementation guide](https://docs.meld.io/docs/whitelabel-api-guide#provider-ui-launch-implementation). ## Prerequisites * The Meld integration is enabled for the target Portal environment. See [Meld integration overview](/integrations/On-Off-Ramp/meld). * You hold a valid Portal Client API session token. See the [Client API quickstart](/apis/quickstart). All examples below use `https://api.portalhq.io`. For sandbox/dev Portal environments, swap the host accordingly. ## Step 1: Discovery Call the discovery endpoints to populate dropdowns and validate selections before opening the widget. All discovery endpoints are `GET` and require no request body. ### Countries ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/clients/me/integrations/meld/discovery/countries \ --header 'Authorization: Bearer [token]' ``` Example response (truncated): ```json theme={null} { "data": [ { "countryCode": "US", "name": "United States", "flagImageUrl": "https://images.meld.io/flags/us.svg", "regions": [ { "regionCode": "CA", "name": "California" }, { "regionCode": "NY", "name": "New York" } ] } ] } ``` ### Fiat currencies ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/clients/me/integrations/meld/discovery/fiat-currencies \ --header 'Authorization: Bearer [token]' ``` ### Crypto currencies ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/clients/me/integrations/meld/discovery/crypto-currencies \ --header 'Authorization: Bearer [token]' ``` Meld doesn't accept a separate chain parameter — each token has a distinct `currencyCode` per chain (a "Meld Code"). See [Meld's cryptocurrency coverage](https://www.meld.io/coverage/cryptocurrencies) for the full list. ### Payment methods ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/clients/me/integrations/meld/discovery/payment-methods \ --header 'Authorization: Bearer [token]' ``` Other discovery endpoints follow the same shape: * `GET /discovery/service-providers` — available providers and their categories/logos. * `GET /discovery/defaults` — default fiat currency and payment methods per country. * `GET /discovery/buy-limits`, `GET /discovery/sell-limits` — min/max/default amounts. * `GET /discovery/kyc-limits` — daily/weekly/monthly limits per KYC tier. Cache discovery responses client-side. Countries, currencies, and payment methods change rarely and re-querying on every page load adds unnecessary latency. ## Step 2: Get a quote Call `POST /retail/quote` to fetch live pricing across providers before opening the widget. The response includes one `MeldQuote` per available provider, sorted by Meld's recommendation logic. ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/meld/retail/quote \ --header 'Authorization: Bearer [token]' \ --header 'Content-Type: application/json' \ --data '{ "countryCode": "US", "sourceCurrencyCode": "USD", "destinationCurrencyCode": "USDC", "sourceAmount": 100 }' ``` | Field | Type | Required | Description | | ------------------------- | --------- | -------- | ---------------------------------------------------------------------- | | `countryCode` | string | Yes | ISO-3166-1 alpha-2 country code (for example `US`). | | `sourceCurrencyCode` | string | Yes | Source currency (fiat for buy, crypto for sell). | | `destinationCurrencyCode` | string | Yes | Destination currency. | | `sourceAmount` | number | Yes | Amount of source currency to convert. | | `walletAddress` | string | No | Destination wallet address (informs provider eligibility). | | `paymentMethodType` | string | No | Filter to a specific payment method (for example `CREDIT_DEBIT_CARD`). | | `serviceProviders` | string\[] | No | Restrict the quote to specific providers. | | `subdivision` | string | No | State/region code, when required by the country. | Whichever side carries crypto — `destinationCurrencyCode` on a buy, `sourceCurrencyCode` on a sell, or both on a transfer — uses a [Meld Code](https://www.meld.io/coverage/cryptocurrencies) that selects both the asset and the chain. Pass the same code into the widget session in Step 3 so the quoted price matches the actual settlement chain. Example response (truncated): ```json theme={null} { "data": { "quotes": [ { "transactionType": "CRYPTO_PURCHASE", "sourceAmount": 100, "sourceCurrencyCode": "USD", "destinationAmount": 99.42, "destinationCurrencyCode": "USDC", "exchangeRate": 0.9942, "transactionFee": 0.5, "totalFee": 0.58, "paymentMethodType": "CREDIT_DEBIT_CARD", "serviceProvider": "TRANSAK" } ] } } ``` Pick a `serviceProvider` from the response — you'll pass it into the widget session in the next step. ## Step 3: Create a widget session Call `POST /retail/widget` with the chosen `serviceProvider` and the same currency/amount fields used for quoting. Meld returns a `widgetUrl` you embed in an iframe (or load via redirect). ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/meld/retail/widget \ --header 'Authorization: Bearer [token]' \ --header 'Content-Type: application/json' \ --data '{ "sessionType": "BUY", "sessionData": { "countryCode": "US", "sourceAmount": "100", "sourceCurrencyCode": "USD", "destinationCurrencyCode": "USDC", "serviceProvider": "TRANSAK", "walletAddress": "0xc118ad00663be7d0360b56a4e9fbb20530bf4693", "redirectUrl": "https://yourapp.example/meld/return" } }' ``` | Field | Type | Required | Description | | ------------------------------------- | ------------------------------- | -------- | --------------------------------------------------------------------------------------------- | | `sessionType` | `"BUY" \| "SELL" \| "TRANSFER"` | Yes | The flow direction. | | `sessionData.countryCode` | string | Yes | ISO-3166-1 alpha-2 country code. | | `sessionData.sourceAmount` | string | Yes | Source amount (string-encoded decimal). | | `sessionData.sourceCurrencyCode` | string | Yes | Source currency. | | `sessionData.destinationCurrencyCode` | string | Yes | Destination currency. | | `sessionData.serviceProvider` | string | Yes | Provider chosen from the quote response. | | `sessionData.walletAddress` | string | No | Destination wallet address. Pre-fills the widget. | | `sessionData.walletTag` | string | No | Memo / destination tag for chains that require it. | | `sessionData.paymentMethodType` | string | No | Pre-selects the payment method. | | `sessionData.lockFields` | string\[] | No | Field names the user cannot modify (for example `["walletAddress"]`). See Meld's docs. | | `sessionData.redirectUrl` | string | No | URL Meld redirects to when the user finishes the flow. | | `externalSessionId` | string | No | Your reference for this session (recorded on Meld's transaction). | | `customerId` | string | No | Meld `customerId` if you have [pre-created a customer](#optional-pre-create-a-meld-customer). | | `bypassKyc` | boolean | No | Skip KYC where allowed by the provider/jurisdiction. Use cautiously. | Example response: ```json theme={null} { "data": { "id": "ses_01HZZZZ", "token": "eyJhbGciOi...", "customerId": "cust_01HXXXX", "externalCustomerId": "cmnescm57000a14ebp8oeqj4k--cmnescm5h000c14ebofkcqdkr--cmnesxy85000346cmwyknl1v7", "externalSessionId": "", "widgetUrl": "https://meldcrypto.com/?token=eyJhbGciOi..." } } ``` Load `widgetUrl` in an iframe or full-page redirect from your app — never echo the URL into a context the end user can copy and reuse, since it embeds a single-use session token. Refer to [Meld's Provider UI Launch Implementation guide](https://docs.meld.io/docs/whitelabel-api-guide#provider-ui-launch-implementation) for iframe sizing guidance and supported `lockFields` values. ## Step 4: Track transaction status The widget drives the user through payment, KYC (when required), and settlement. To inspect the resulting transaction from your backend, look it up by widget session ID: ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/clients/me/integrations/meld/retail/transactions/sessions/ses_01HZZZZ \ --header 'Authorization: Bearer [token]' ``` Or by Meld transaction ID: ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/clients/me/integrations/meld/retail/transactions/tx_01HAAAA \ --header 'Authorization: Bearer [token]' ``` Or list all transactions for the current client: ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/clients/me/integrations/meld/retail/transactions \ --header 'Authorization: Bearer [token]' ``` Example response: ```json theme={null} { "data": { "transaction": { "id": "tx_01HAAAA", "sessionId": "ses_01HZZZZ", "status": "SETTLED", "transactionType": "CRYPTO_PURCHASE", "serviceProvider": "TRANSAK", "sourceAmount": 100, "sourceCurrencyCode": "USD", "destinationAmount": 99.42, "destinationCurrencyCode": "USDC", "destinationWalletAddress": "0xc118ad00663be7d0360b56a4e9fbb20530bf4693", "createdAt": "2026-05-07T12:00:00Z", "updatedAt": "2026-05-07T12:08:42Z" } } } ``` Prefer Meld webhooks over polling for transaction lifecycle updates. Configure them in [Meld Dashboard](https://dashboard.meld.io/) — see [Webhooks](/integrations/On-Off-Ramp/meld-webhooks). ## Optional: Pre-create a Meld customer The widget collects user identity inside the iframe, so you do not need a pre-existing Meld customer record to launch a session. Pre-creating one is useful when you want to: * Pre-fill identifying fields to speed up KYC * Track Meld history for a Portal client outside of widget sessions * Associate multiple sessions with a persistent Meld customer record Call `POST /customers` once per Portal client. Portal derives an identifier from your custodian + environment + client identity, so you don't pass it yourself. Meld returns this value as `externalId` on customer objects and as `externalCustomerId` on quote, widget session, and transaction objects — both fields hold the same value. ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/meld/customers \ --header 'Authorization: Bearer [token]' \ --header 'Content-Type: application/json' \ --data '{ "name": { "firstName": "Jane", "lastName": "Doe" }, "email": "jane@example.com", "phone": "+15551234567", "dateOfBirth": "1990-01-15", "type": "INDIVIDUAL" }' ``` Example response: ```json theme={null} { "data": { "id": "cust_01HXXXX", "accountId": "acct_01HYYYY", "externalId": "cmnescm57000a14ebp8oeqj4k--cmnescm5h000c14ebofkcqdkr--cmnesxy85000346cmwyknl1v7", "name": { "firstName": "Jane", "lastName": "Doe" }, "email": "jane@example.com", "type": "INDIVIDUAL", "status": "ACTIVE" } } ``` All body fields are optional, but providing identifying data up front helps Meld's KYC providers complete checks faster. Required fields vary by service provider and country — query `GET /discovery/kyc-limits` to understand tier requirements. If a Meld customer already exists for this Portal client, the call returns `409 Conflict` with the existing customer ID at `details.meldCustomerId` on the error envelope. Retrieve the full record with `GET /customers`: ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/clients/me/integrations/meld/customers \ --header 'Authorization: Bearer [token]' ``` `GET /customers` returns a list-shaped response under `data.customers`. If a customer exists for the current Portal client, pass `data.customers[0].id` as `customerId` on `POST /retail/widget` (Step 3) to associate the widget session with that Meld customer record. If `data.customers` is empty, there is no existing customer to reuse — omit `customerId` or create one first with `POST /customers`. ## Sandbox testing Meld provides separate API keys, base URLs, and test data for sandbox. Configure your Portal **Development** environment with a sandbox Meld API key, then run the BUY happy path: * Country: `US` * Source: `USD` `100` * Destination: `USDC` * Service provider: any returned by `/retail/quote` Test card numbers, bank credentials, and KYC bypass details are documented behind login at [Meld's Getting Started guide](https://docs.meld.io/docs/getting-started). ## Error handling Portal forwards Meld's HTTP status codes (4xx / 5xx) and preserves the upstream error message where possible. The full Meld error envelope is documented at [docs.meld.io/reference](https://docs.meld.io/reference/). Portal-side errors you may see: * `400 Meld integration is not enabled` — turn on the Meld integration for the current Portal environment in the [Portal Dashboard](https://app.portalhq.io). * `400 Meld API key is not configured for this environment` — paste a valid Meld API key into the integration config. * `409 Conflict` (on `POST /customers`) — a Meld customer already exists for this Portal client. Use `GET /customers` instead. ## Next steps Browse every Meld endpoint Portal exposes. Configure Meld webhooks in Meld Dashboard for transaction lifecycle updates. # Overview Source: https://docs.portalhq.io/integrations/On-Off-Ramp/noah Connect your Noah account to Portal to manage virtual bank accounts and global payouts. ## Overview Portal integrates with [Noah](https://www.noah.com/) so you can manage virtual bank accounts and global payouts through Portal Client API endpoints. This page covers one-time setup in the Portal Dashboard and links to workflow guides. ## Steps to enable the Portal Noah integration ### 1. Create your Noah credentials * Create or access your Noah business account. * Generate a Noah API key with request-signing key configuration (see [Request Signing Key Generation](https://docs.noah.com/api-concepts/authentication/signing/#request-signing-key-generation)). Noah Dashboard API configuration section showing API Keys and Webhooks tabs Noah Dashboard create key modal showing API key label and public key fields Keep your Noah credentials secure. You will configure them in Portal in the next step, and Portal will proxy requests server-side. Portal requires Noah request signing credentials for both sandbox and production environments. ### 2. Enable the Noah integration in Portal 1. Open the [Portal Dashboard](https://app.portalhq.io). 2. Navigate to the `Integrations` page. 3. Locate `Noah` and add the integration. 4. Provide your Noah API key. 5. Provide your Noah signing private key in the integration configuration. 6. Save changes for your selected Portal environment. Integrations are environment-scoped. Configure Noah separately for each environment (for example Development and Production). ### 3. Configure business fees (optional) Set a business fee to collect your own service fee on top of Noah's fees on every payin and payout. 1. On the `Integrations` page, open the `Noah` configuration. 2. Under `Business fees`, set a percentage and/or flat fee for pay-ins and pay-outs. 3. Save changes for your selected Portal environment. Business fees are enforced server-side and applied automatically. Clients can't set or override them per request, so fees are never exposed to or controlled by your end users. ### 4. Configure webhooks in Noah Dashboard Configure your webhook endpoint and subscribe to Noah events directly in Noah Dashboard. See [Webhooks](/integrations/On-Off-Ramp/noah-webhooks). ## Supported networks Portal accepts CAIP-2 network identifiers and maps them to Noah network names. | CAIP-2 | Noah network | | ----------------- | --------------------- | | `eip155:1` | `Ethereum` | | `eip155:11155111` | `EthereumTestSepolia` | | `eip155:8453` | `Base` | | `eip155:84532` | `BaseTestSepolia` | | `eip155:100` | `Gnosis` | | `eip155:10200` | `GnosisTestChiado` | | `eip155:137` | `PolygonPos` | | `eip155:80002` | `PolygonTestAmoy` | | `solana:mainnet` | `Solana` | | `solana:devnet` | `SolanaDevnet` | ## SDK guides * [Noah virtual accounts and payouts](/sdks/web/guide/noah) using the Web SDK * [Noah virtual accounts and payouts](/sdks/react-native/guide/noah) using the React Native SDK * [Noah virtual accounts and payouts](/sdks/ios/guide/noah) using the iOS SDK * [Noah virtual accounts and payouts](/sdks/android/guide/noah) using the Android SDK ## Workflow guides * [KYC Onboarding](/integrations/On-Off-Ramp/noah-kyc) * [Payins](/integrations/On-Off-Ramp/noah-payins) * [Payouts](/integrations/On-Off-Ramp/noah-payouts) * [Webhooks](/integrations/On-Off-Ramp/noah-webhooks) ## API reference Open the interactive Noah endpoints reference. If you are implementing payin and payout flows, start with KYC first. Payin and payout initiation endpoints require an approved Noah KYC status. # KYC Onboarding Source: https://docs.portalhq.io/integrations/On-Off-Ramp/noah-kyc Initiate Noah hosted onboarding and receive KYC lifecycle updates through Noah webhooks. ## Overview Use Noah hosted onboarding through Portal Client API to verify your client before running payins or payout flows. ## 1. Initiate KYC Call `POST /customers/kyc` to create (or return) a hosted onboarding session. ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/noah/customers/kyc \ --header 'Authorization: Bearer [token]' \ --header 'Content-Type: application/json' \ --data '{ "returnUrl": "https://example.com/noah/return", "fiatOptions": [{ "fiatCurrencyCode": "USD" }] }' ``` Example response: ```json theme={null} { "data": { "hostedUrl": "https://checkout.sandbox.noah.com/kyc?session=..." } } ``` `POST /customers/kyc` returns the hosted URL only. KYC approval status is evaluated later via Noah webhooks and Noah customer verification checks during payin/payout flows. `returnUrl` must be a valid HTTPS URL. ## 2. Redirect the user to hosted onboarding Send the user to `data.hostedUrl` so they can complete the Noah flow. ## 3. Configure Noah webhooks for KYC updates Set up Noah webhooks in Noah Dashboard to receive KYC lifecycle events directly from Noah. * Subscribe to `Customer` events for KYC status lifecycle updates. * [Noah Webhooks](/integrations/On-Off-Ramp/noah-webhooks) Payins and payout flows require an approved Noah KYC status. Enforce this check using Noah webhooks and customer verification checks before calling payin/payout initiation endpoints. # Payins Source: https://docs.portalhq.io/integrations/On-Off-Ramp/noah-payins Assign virtual bank account details and process fiat-to-stablecoin settlement with Noah payins through Portal. ## Overview Noah payins let your client receive virtual bank account details, send fiat funds, and settle into stablecoins. ## 1. Prerequisite: approved KYC Ensure the client completed Noah onboarding and has `APPROVED` status. See [Noah KYC onboarding](/integrations/On-Off-Ramp/noah-kyc). ## 2. Initiate payin Call `POST /payins` to create a bank-deposit-to-onchain workflow. ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/noah/payins \ --header 'Authorization: Bearer [token]' \ --header 'Content-Type: application/json' \ --data '{ "fiatCurrency": "USD", "cryptoCurrency": "USDC_TEST", "network": "eip155:11155111", "destinationAddress": "0xc118ad00663be7d0360b56a4e9fbb20530bf4693" }' ``` In Noah's sandbox, cryptocurrency tickers must be suffixed with `_TEST` (e.g. `USDC_TEST`). Drop the suffix in production. See [Noah's sandbox naming convention](https://docs.noah.com/products/global-payouts-api/#sandbox-cryptocurrency-naming-convention). Business fees are configured per environment in the Portal Dashboard (Integrations → Noah → Business fees) and applied automatically to every payin. They can't be set per request. Example response (truncated to key fields): ```json theme={null} { "data": { "payinId": "cmmw70gq500053saq9urbu8ag", "bankDetails": { "accountHolderName": "John Mock-Doe", "accountNumber": "900366164700", "bankAddress": { "city": "Kansas City", "country": "US", "postCode": "64108", "state": "MO", "street": "1801 Main St" }, "bankCode": "101019644", "bankName": "LEAD BANK", "network": "EthereumTestSepolia", "paymentMethodId": "Bank/Ach/USD/101019644/900366164700/cmmw6tzqc00003saqzbgwueb5", "paymentMethodType": "BankAch", "relatedPaymentMethods": [ { "details": { "accountNumber": "900366164700", "bankCode": "101019644" }, "paymentMethodId": "Bank/Fedwire/USD/101019644/900366164700/cmmw6tzqc00003saqzbgwueb5", "paymentMethodType": "BankFedwire" } ] } } } ``` ## 3. Display bank instructions Show `bankDetails` to your user in your UI so they can submit their fiat transfer. ## 4. (Optional, sandbox) Simulate fiat deposit To test payin flows in sandbox, call `POST /payins/simulate` with the `paymentMethodId` returned in `bankDetails`. ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/noah/payins/simulate \ --header 'Authorization: Bearer [token]' \ --header 'Content-Type: application/json' \ --data '{ "paymentMethodId": "Bank/Fedwire/USD/101019644/900366164700/cmmw6tzqc00003saqzbgwueb5", "fiatAmount": "100.00", "fiatCurrency": "USD" }' ``` Example response: ```json theme={null} { "data": { "fiatDepositId": "accdeef0-c364-5b67-976c-f90a50aa957c" } } ``` ## 5. Configure Noah webhooks for payin updates Set up Noah webhooks in Noah Dashboard to receive payin lifecycle updates directly from Noah. * Subscribe to `FiatDeposit` and `Transaction` events for payin status and settlement updates. * [Noah Webhooks](/integrations/On-Off-Ramp/noah-webhooks) ## 6. Monitor payin progress Use `FiatDeposit` and `Transaction` webhook events plus Noah Dashboard delivery logs for payin reconciliation and status tracking. Acknowledge webhook deliveries quickly with `2XX`, then process events asynchronously. # Payouts Source: https://docs.portalhq.io/integrations/On-Off-Ramp/noah-payouts Run Noah payout workflows through Portal from channel discovery and form rendering to quote and payout initiation. ## Overview Noah payouts let your client convert stablecoins to fiat and disburse through local payout rails. ## 1. Prerequisite: approved KYC Ensure the client has completed Noah onboarding and has `APPROVED` status. See [Noah KYC onboarding](/integrations/On-Off-Ramp/noah-kyc). ## 2. Get supported payout countries ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/clients/me/integrations/noah/payouts/countries \ --header 'Authorization: Bearer [token]' ``` The `countries` map includes many additional country/currency combinations based on Noah support. ## 3. Get payout channels Call `GET /payouts/channels` with at least `cryptoCurrency`. `country` and `fiatCurrency` are optional filters; pass either or both to narrow results. ```bash theme={null} curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/noah/payouts/channels?country=BR&cryptoCurrency=USDC_TEST&fiatCurrency=BRL' \ --header 'Authorization: Bearer [token]' ``` In Noah's sandbox, cryptocurrency tickers must be suffixed with `_TEST` (e.g. `USDC_TEST`). Drop the suffix in production. See [Noah's sandbox naming convention](https://docs.noah.com/products/global-payouts-api/#sandbox-cryptocurrency-naming-convention). Optional query params: * `fiatAmount` — pricing context for the returned `calculated.totalFee`. * `paymentMethodId` — scope channels to a specific existing payment method. * `pageSize` — page size (1-100). * `pageToken` — pagination token from a previous response. ## 4. (Optional) Get saved payment methods Call `GET /payouts/payment-methods` to list saved payout destinations for the approved customer. If you want to use a saved payout destination, you can pass its `id` as `paymentMethodId` in `POST /payouts/quote`. ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/clients/me/integrations/noah/payouts/payment-methods \ --header 'Authorization: Bearer [token]' ``` Example response (truncated to key fields): ```json theme={null} { "data": { "paymentMethods": [ { "id": "Bank/Ach/USD/021000021/123456789/cmmw6tzqc00003saqzbgwueb5", "paymentMethodCategory": "Bank", "customerId": "cmmw6tzqc00003saqzbgwueb5", "country": "US", "capabilities": { "payinTo": false, "payoutFrom": false, "payoutTo": true }, "displayDetails": { "type": "FiatPaymentMethodBankDisplay", "accountNumber": "123456789", "bankCode": "021000021" }, "accountHolderDetails": { "name": { "firstName": "John", "lastName": "Mock-Doe" } }, "issuerDetails": {} } ] } } ``` `pageToken` may also be present when pagination is returned. ## 5. Get dynamic form for selected channel Use `GET /payouts/channels/:channelId/form` and render the returned `formSchema`. ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/clients/me/integrations/noah/payouts/channels/[channelId]/form \ --header 'Authorization: Bearer [token]' ``` Example response (truncated to key fields): ```json theme={null} { "data": { "formMetadata": { "contentHash": "eda5d74d98e3e7429644fd7f74d4979638eca0e9aa7422091a600d85c1f3422a" }, "formSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "AccountHolderName": { "type": "object", "title": "Account Holder Name" }, "AccountHolderAddress": { "type": "object", "title": "Account Holder Address" }, "BankDetails": { "type": "object", "title": "Bank Details" }, "PaymentPurpose": { "type": "string", "title": "Payment Purpose" } }, "required": [ "AccountHolderName", "AccountHolderAddress", "BankDetails", "PaymentPurpose" ] } } } ``` `formSchema` can include additional nested validation rules (`allOf`, `if`/`then`, `minLength`, `maxLength`, `pattern`) that should be rendered and validated in your form UI. ## 6. Quote payout Call `POST /payouts/quote` to validate form data and get fee + estimate. `paymentMethodId` is optional and is only needed when quoting against a saved payment method from step 4. Provide **exactly one** of `fiatAmount` or `cryptoAmount`: * `fiatAmount` — amount the recipient receives. The quote derives the equivalent crypto. * `cryptoAmount` — exact crypto the depositor will send. The quote derives the equivalent fiat (returned in the response's `fiatAmount` disclosure field). Pass `quoted: true` to request a fixed-rate quote. When `quoted` is true and all form steps are complete, the response includes a `quote` object with a `signedQuote` and `expiry` issued by Noah. Forward `quote.signedQuote` unchanged into the **Quoted trigger** on `POST /payouts` (as `trigger.SignedQuote`) to lock the rate end-to-end. See the trigger variants in step 7. Business fees are configured per environment in the Portal Dashboard (Integrations → Noah → Business fees) and applied automatically to every payout. They can't be set per request. ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/noah/payouts/quote \ --header 'Authorization: Bearer [token]' \ --header 'Content-Type: application/json' \ --data '{ "channelId": "214eab50-e22b-5e0f-b487-37dc1addee90", "cryptoCurrency": "USDC_TEST", "fiatAmount": "0.01", "fiatCurrency": "USD", "form": { "AccountHolderName": { "AccountHolderType": "Individual", "Name": { "FirstName": "John", "LastName": "Mock-Doe" } }, "AccountHolderAddress": { "Address": "123 Main St", "City": "Miami", "PostalCode": "33139", "State": "FL" }, "BankDetails": { "AccountNumber": "900711447286", "BankCode": "101019644", "AccountType": "Checking" }, "PaymentPurpose": "Personal transfer", "Reference": "Optional reference" } }' ``` Example response (truncated to key fields): ```json theme={null} { "data": { "payoutId": "cmmyatsdk0000wy2en3lof5jj", "formSessionId": "d5f81409-5306-476b-84d6-6be2b5656a5a", "cryptoAmountEstimate": "0.010004", "cryptoAuthorizedAmount": "0.010004", "totalFee": "0.01", "cryptoCurrency": "USDC_TEST", "fiatCurrency": "USD", "fiatAmount": "0.01", "rate": "0.999", "breakdown": [ { "type": "ChannelFee", "amount": "0.000004" }, { "type": "BusinessFee", "amount": "0" }, { "type": "Remaining", "amount": "0.01" } ] } } ``` When `quoted: true` was sent in the request and all form steps are complete, the response also includes a `quote` object: ```json theme={null} { "data": { "quote": { "signedQuote": "eyJhbGciOi...", "expiry": "2026-03-24T08:00:55Z" } } } ``` To lock the rate, pass `quote.signedQuote` unchanged on `POST /payouts` inside the Quoted trigger as `trigger.SignedQuote`. The deposit must arrive and finalize before `quote.expiry`; otherwise the workflow proceeds at the market rate at execution time. ## 7. Initiate payout Call `POST /payouts` using `payoutId` returned from quote. `nonce` must be unique per transaction attempt (max 36 characters). Reuse the same nonce only when retrying the same payout request for idempotency. ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/clients/me/integrations/noah/payouts \ --header 'Authorization: Bearer [token]' \ --header 'Content-Type: application/json' \ --data '{ "payoutId":"cmmyatsdk0000wy2en3lof5jj", "sourceAddress":"DEeUjZtzWWiM6sJPBFsR5iL9AeSEtvtuh4ycHM4LHyTK", "expiry":"2026-03-24T23:59:59Z", "nonce":"nonce-20260320-02388933", "network":"solana:devnet" }' ``` Example response: ```json theme={null} { "data": { "destinationAddress": "4dguFohaa8F9MvRtZecGsHCvnGqzYPyGyusP6BWu4zLK", "conditions": [ { "amountConditions": [ { "comparisonOperator": "EQ", "value": "0.010004" } ], "cryptoCurrency": "USDC_TEST", "destinationAddress": "4dguFohaa8F9MvRtZecGsHCvnGqzYPyGyusP6BWu4zLK", "network": "SolanaDevnet" } ] } } ``` After receiving this response, submit the onchain transfer to the returned `destinationAddress` and ensure it satisfies the returned `conditions` (network and amount constraints). ### Trigger variants You can pass an explicit `trigger` to control how the sell rule fires. The `Type` field selects the variant: * **`SingleOnchainDepositSourceTriggerInput`** *(default)* — executes once. `Conditions[*]` must carry `AmountConditions`. Portal forwards the persisted `fiatAmount` from the quote when set. * **`PermanentOnchainDepositSourceTriggerInput`** — fires on every matching deposit; the entire deposited crypto amount is always sold. `Conditions[*]` carries `Network` only — **omit** `AmountConditions`. Optional `NetworkAgnostic: true` matches deposits on any network. * **`QuotedOnchainDepositSourceTriggerInput`** — executes once and locks the conversion rate from a prior `SellQuote`. Requires `SignedQuote` on the trigger (from `quote.signedQuote` returned by `POST /payouts/quote`). `Conditions[*]` carries `Network` only — **omit** `AmountConditions`. `Expiry` is optional and defaults to the bound quote's expiry. **Permanent trigger** (no `AmountConditions`): ```json theme={null} { "trigger": { "Type": "PermanentOnchainDepositSourceTriggerInput", "Conditions": [{ "Network": "SolanaDevnet" }], "SourceAddress": "DEeUjZtzWWiM6sJPBFsR5iL9AeSEtvtuh4ycHM4LHyTK", "Expiry": "2026-03-24T23:59:59Z", "Nonce": "nonce-20260320-02388933", "NetworkAgnostic": false } } ``` **Quoted trigger** (carry the `signedQuote` returned by `POST /payouts/quote` as `trigger.SignedQuote`; no `AmountConditions`; `Expiry` defaults to the bound quote's expiry): ```json theme={null} { "trigger": { "Type": "QuotedOnchainDepositSourceTriggerInput", "SignedQuote": "eyJhbGciOi...", "Conditions": [{ "Network": "SolanaDevnet" }], "SourceAddress": "DEeUjZtzWWiM6sJPBFsR5iL9AeSEtvtuh4ycHM4LHyTK", "Nonce": "nonce-20260320-02388933" } } ``` `ComparisonOperator` values for the Single trigger: `EQ`, `LTEQ`, `GTEQ`. ## 8. Configure Noah webhooks for payout updates Set up Noah webhooks in Noah Dashboard to receive payout lifecycle and transaction updates directly from Noah. * Subscribe to `Transaction` events for payout execution and completion updates. * [Noah Webhooks](/integrations/On-Off-Ramp/noah-webhooks) ## 9. Monitor payout progress Use `Transaction` webhook events and Noah Dashboard delivery logs to track payout execution and reconciliation. `POST /payouts` only works for payout intents in `PREPARED` status (created by `POST /payouts/quote`). # Webhooks Source: https://docs.portalhq.io/integrations/On-Off-Ramp/noah-webhooks Set up Noah Dashboard webhooks to receive KYC, payin, and payout lifecycle events. ## Overview Configure webhooks directly in Noah Dashboard. Portal does not relay Noah webhook events. ## 1. Add your endpoint in Noah Dashboard 1. Open Noah Dashboard (correct environment). 2. Go to webhook settings. 3. Add your HTTPS webhook URL. 4. Save. Noah Dashboard create webhook modal showing URL input and event type selector ## 2. Select events Subscribe to: * `Customer` (KYC lifecycle) * `FiatDeposit` (payin updates) * `Transaction` (payin/payout transaction updates) Noah Dashboard event type dropdown showing Customer, Fiat Deposit, and Transaction options ## 3. Verify delivery * Send a test event from Noah Dashboard. * Confirm your endpoint returns `2XX`. * Process events asynchronously after acknowledgment. # Blockaid Source: https://docs.portalhq.io/integrations/Security/blockaid Connect your Blockaid account to Portal and start scanning transactions, addresses, tokens, and URLs for security risks. ## Overview Portal integrates with [Blockaid](https://www.blockaid.io/) so you can scan transactions, addresses, tokens, and URLs for security threats through Portal APIs and SDKs. This page covers the one‑time setup in Blockaid and the Portal Dashboard. ## Steps to enable the Portal Blockaid integration ### 1. Sign up for Blockaid * Create or access your Blockaid account [here](https://blockaid.io/?referrer=portal). * Generate a Blockaid API key in your Blockaid dashboard. Keep your API keys secure. You'll paste them into Portal in the next step. ### 2. Enable the Blockaid integration in Portal 1. Open the [Portal Dashboard](https://app.portalhq.io). 2. Navigate to the `Integrations` page. 3. Locate the `Blockaid` integration and add it. 4. Paste your Blockaid API key for your selected Portal environment and save. 5. (Optional) Enable **Integrated Scans** to automatically scan all EVM, Solana, and TRON (mainnet) transactions with Blockaid before signing. Enabling Integrated Scans will increase transaction signing times, as each transaction must be scanned by Blockaid before signing is allowed. Once saved, Portal will proxy your SDK/API calls to Blockaid using this key securely from Portal's servers, so you won't need to embed it in your application code. Integrations are environment‑scoped. Repeat these steps per Portal environment. ## Next steps Implement the flow in your app using our SDK guides for Blockaid: * [Enhance your wallets' security with Blockaid](/sdks/ios/guide/blockaid) using the iOS SDK * [Enhance your wallets' security with Blockaid](/sdks/android/guide/blockaid) using the Android SDK * [Enhance your wallets' security with Blockaid](/sdks/web/guide/blockaid) using the Web SDK * [Enhance your wallets' security with Blockaid](/sdks/react-native/guide/blockaid) using the React Native SDK * [Enhance your wallets' security with Blockaid](/sdks/flutter/guide/blockaid) using the Flutter SDK # Hypernative Source: https://docs.portalhq.io/integrations/Security/hypernative Connect your Hypernative account to Portal and start scanning transactions, addresses, tokens, NFTs, and URLs for security risks. ## Overview Portal integrates with [Hypernative](https://www.hypernative.io/) so you can scan transactions, addresses, tokens, NFTs, and URLs for security threats through Portal APIs and SDKs. This page covers the one‑time setup in Hypernative and the Portal Dashboard. ## Steps to enable the Portal Hypernative integration ### 1. Sign up for Hypernative * Create or access your Hypernative account [here](https://app.hypernative.xyz/login?referrer=portal). * Generate a Hypernative `x-client-id` and `x-client-secret` in your Hypernative dashboard. Keep your API keys secure. You'll paste them into Portal in the next step. ### 2. Enable the Hypernative integration in Portal 1. Open the [Portal Dashboard](https://app.portalhq.io). 2. Navigate to the `Integrations` page. 3. Locate the `Hypernative` integration and add it. 4. Paste your Hypernative `x-client-id` and `x-client-secret` for your selected Portal environment and save. 5. (Optional) Enable **Integrated Scans** to automatically scan all EVM and Solana transactions with Hypernative before signing. Enabling Integrated Scans will increase transaction signing times, as each transaction must be scanned by Hypernative before signing is allowed. Once saved, Portal will proxy your SDK/API calls to Hypernative using this key securely from Portal's servers, so you won't need to embed it in your application code. Integrations are environment‑scoped. Repeat these steps per Portal environment. ## Next steps Implement the flow in your app using our SDK guides for Hypernative: * [Enhance your wallets' security with Hypernative](/sdks/ios/guide/hypernative) using the iOS SDK * [Enhance your wallets' security with Hypernative](/sdks/android/guide/hypernative) using the Android SDK * [Enhance your wallets' security with Hypernative](/sdks/react-native/guide/hypernative) using the React Native SDK * [Enhance your wallets' security with Hypernative](/sdks/web/guide/hypernative) using the Web SDK * [Enhance your wallets' security with Hypernative](/sdks/flutter/guide/hypernative) using the Flutter SDK # Li.Fi Source: https://docs.portalhq.io/integrations/Trading/lifi Connect your Li.Fi account to Portal and start bridging & swapping across chains. ## Overview Portal integrates with [Li.Fi](https://portal.li.fi/signup?referrer=portal) so you can bridge and swap tokens across multiple chains through Portal APIs and SDKs. This page covers the one‑time setup in Li.Fi and the Portal Dashboard. ## Steps to enable the Portal Li.Fi integration ### 1. Sign up for Li.Fi * Create or access your Li.Fi account [here](https://portal.li.fi/signup?referrer=portal). * Generate a Li.Fi API key in your Li.Fi dashboard. Keep your API keys secure. You'll paste it into Portal in the next step. ### 2. Enable the Li.Fi integration in Portal 1. Open the [Portal Dashboard](https://app.portalhq.io). 2. Navigate to the `Integrations` page. 3. Locate the `Li.Fi` integration and add it. 4. Paste your Li.Fi API key for your selected Portal environment and save. Once saved, Portal will proxy your SDK/API calls to Li.Fi using this key securely from Portal's servers, so you won't need to embed it in your application code. Integrations are environment‑scoped. Repeat these steps per Portal environment. ## Gas-sponsored Solana swaps Some Solana routes create an intermediate token account partway through the swap (for example a WSOL account for a route that hops through a SOL pool). That account creation is funded by the sending wallet, so a gas-sponsored wallet holding 0 SOL would otherwise fail on the rent for it. Li.Fi exposes an `svmSponsor` field on the quote and routes requests: the account it names is used as the fee payer **and** the funder for any account creation in the route, so your sponsor covers that rent. When your Portal environment has a Solana gas sponsor enabled, Portal fills `svmSponsor` for you on Solana source routes, so no change is needed in your app. To use a different account, pass `svmSponsor` (a valid Solana address) on the request and Portal will use it instead. Invalid values are ignored and fall back to the environment gas sponsor. On `/lifi/quote` it is a top-level field (`svmSponsor`); on `/lifi/routes` it goes inside `options` (`options.svmSponsor`). `svmSponsor` is Solana (SVM) only and applies when the source chain is Solana. It is available on `/lifi/quote` and `/lifi/routes`; routes bake it into the returned steps, so `/lifi/route-step-details` inherits it. ## Selecting Solana swap routes Li.Fi routes Solana swaps across several DEX aggregators, and the aggregator it picks (and how much compute the resulting transaction uses) can differ on each quote for the same request. Occasionally it returns a compute-heavy multi-hop route whose transaction exceeds Solana's compute unit budget, so submitting it fails simulation with `ComputationalBudgetExceeded`. Because the route is chosen fresh per quote, re-requesting often returns a lighter route that succeeds. For more predictable routing, constrain which exchanges Li.Fi may use with `allowExchanges` (allowlist) or `denyExchanges` (blocklist). For Solana stablecoin swaps, limiting to lighter-compute routes like `jupiter` or `fly` is a good starting point: ```json theme={null} { "fromChain": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "toChain": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "fromToken": "USDC", "toToken": "EURC", "fromAmount": "1000000", "fromAddress": "", "allowExchanges": ["jupiter", "fly"] } ``` The exchange keys are Li.Fi's DEX aggregator identifiers (for example `jupiter`, `fly`, `okx`, `titan`). On `/lifi/quote` these are top-level fields (`allowExchanges`, `denyExchanges`, `preferExchanges`). On `/lifi/routes` they live under `options.exchanges` (`allow`, `deny`, `prefer`). Portal forwards them to Li.Fi unchanged. These filters work on any chain, not just Solana. On Solana they are the simplest way to avoid the intermittent `ComputationalBudgetExceeded` failures that come from heavier multi-hop routes. ## Next steps Implement the flow in your app using our SDK guides for Li.Fi: * [Bridge & Swap with Li.Fi](/apis/enclave-mpc/guide/lifi) using the Enclave MPC API * [Bridge & Swap with Li.Fi](/sdks/ios/guide/lifi) using the iOS SDK * [Bridge & Swap with Li.Fi](/sdks/android/guide/lifi) using the Android SDK * [Bridge & Swap with Li.Fi](/sdks/web/guide/lifi) using the Web SDK * [Bridge & Swap with Li.Fi](/sdks/react-native/guide/lifi) using the React Native SDK * [Bridge & Swap with Li.Fi](/sdks/flutter/guide/lifi) using the Flutter SDK # 0x Source: https://docs.portalhq.io/integrations/Trading/zerox Connect your 0x account to Portal and start swapping and bridging tokens. ## Overview Portal integrates with [0x](https://dashboard.0x.org/create-account?referred_by=portal) so you can swap tokens and bridge across chains through Portal APIs and SDKs. This page covers the one‑time setup in 0x and the Portal Dashboard. ## Steps to enable the Portal 0x integration ### 1. Sign up for 0x * Create or access your 0x account [here](https://dashboard.0x.org/create-account?referred_by=portal). * Generate a 0x API key in your 0x dashboard. Keep your API keys secure. You'll paste it into Portal in the next step. ### 2. Enable the 0x integration in Portal 1. Open the [Portal Dashboard](https://app.portalhq.io). 2. Navigate to the `Integrations` page. 3. Locate the `0x` integration and add it. 4. Paste your 0x API key for your selected Portal environment and save. Once saved, Portal will proxy your SDK/API calls to 0x using this key securely from Portal's servers, so you won't need to embed it in your application code. Integrations are environment‑scoped. Repeat these steps per Portal environment. ## Cross-Chain Swaps (Experimental) Cross-chain swap endpoints are experimental and require explicit enablement by both the Portal and 0x teams before they can be used. Contact your Portal account manager to request access. Portal also supports cross-chain swaps through 0x, allowing you to bridge and swap tokens across different EVM chains in a single operation. Cross-chain swaps use bridge providers like Across, Relay, and Stargate under the hood. To use cross-chain swaps, your 0x API key must have the cross-chain product enabled on the [0x dashboard](https://dashboard.0x.org). Standard 0x API keys only support same-chain swaps. ## Next steps Implement the flow in your app: **Same-chain swaps:** * [Swap Tokens with 0x](/sdks/ios/guide/zerox) using the iOS SDK * [Swap Tokens with 0x](/sdks/android/guide/zerox) using the Android SDK * [Swap Tokens with 0x](/sdks/web/guide/zero-x) using the Web SDK * [Swap Tokens with 0x](/sdks/react-native/guide/zerox) using the React Native SDK * [Swap Tokens with 0x](/sdks/flutter/guide/zerox) using the Flutter SDK **Cross-chain swaps (Experimental):** * [Cross-Chain Swap with 0x](/apis/enclave-mpc/guide/zerox-cross-chain) using the Enclave MPC API # Yield.xyz Source: https://docs.portalhq.io/integrations/Yield/yield-xyz Connect your Yield.xyz account to Portal, store your API key, and let your users earn yield, lend and borrow. ## Overview Portal integrates with [Yield.xyz](https://dashboard.yield.xyz/sign-up/register-interest?referred_by=portal) so your users can, with their Portal wallets: * **Earn yield**: discover 1,000+ staking, lending-pool and liquidity opportunities, enter / manage / exit positions, and track history. * **Lend and borrow**: supply collateral, borrow, repay and withdraw on Aave, Morpho Blue, SparkLend and Lista, with positions, health factors and liquidation history. Both run through the same Yield.xyz account and API key, and the same `Yield.xyz` integration in the Portal Dashboard. Portal proxies every call server-side, so the key never ships in your app. This page covers the one‑time setup; the per-capability guides are linked at the bottom. ## Steps to enable the Portal Yield.xyz integration ### 1. Sign up for Yield.xyz * Create or access your Yield.xyz account [here](https://dashboard.yield.xyz/sign-up/register-interest?referred_by=portal). * **Yield:** click on each yield type in the Yield.xyz dashboard and tick the checkboxes by each yield opportunity to enable them. * **Lend & borrow:** enable the lending markets you want to offer (for example Aave V3 on Base). Only enabled markets are returned by default; see the [Borrow guide](/integrations/Yield/yield-xyz-borrow) for details. Yield.xyz Dashboard Yield.xyz Staking Yields * Then, generate `2` Yield.xyz API keys in your Yield.xyz dashboard (one for your Portal `Development` environment and one for your Portal `Production` environment). Each key covers yield, lending and borrowing. Keep your API keys secure. You'll paste it into Portal in the next step. ### 2. Enable the Yield.xyz integration in Portal 1. Open the [Portal Dashboard](https://app.portalhq.io). 2. Navigate to the `Integrations` page. 3. Locate the `Yield.xyz` integration (under `Yield`, badged `YIELD`, `LEND` and `BORROW`) and add it. 4. Paste your Yield.xyz API key for your selected Portal environment and save. Once saved, Portal will proxy your SDK/API calls to Yield.xyz using this key securely from Portal's servers, so you won't need to embed it in your application code. Enabling the integration turns on yield, lending and borrowing together; there is nothing separate to add. Integrations are environment‑scoped. Repeat these steps per Portal environment. Need some advice on legal disclaimers when integrating Yield.xyz? Check out [Yield.xyz's disclaimer guidelines here](https://stakekit.notion.site/Yield-Disclaimer-Index-2a35318e934e806f863fecea64be38a8). ## Next steps ### Earn yield Implement the yield flow in your app using our guides: * [Earn with Yield.xyz](/apis/enclave-mpc/guide/yield-xyz) using the Enclave MPC API * [Earn with Yield.xyz](/sdks/android/guide/yield-xyz) using the Android SDK * [Earn with Yield.xyz](/sdks/ios/guide/yield-xyz) using the iOS SDK * [Earn with Yield.xyz](/sdks/web/guide/yield-xyz) using the Web SDK * [Earn with Yield.xyz](/sdks/react-native/guide/yield-xyz) using the React Native SDK ### Lend and borrow * [Lend and borrow with Yield.xyz](/apis/enclave-mpc/guide/yield-xyz-borrow) using the Enclave MPC API * [Yield.xyz Borrow](/integrations/Yield/yield-xyz-borrow): setup, endpoint overview and the [API reference](/api-reference/yieldxyz-borrow/get-positions). SDK guides (Web, iOS, Android, React Native) will be added in a separate release. # Yield.xyz Borrow Source: https://docs.portalhq.io/integrations/Yield/yield-xyz-borrow Let your users supply collateral and borrow assets on Aave, Morpho, SparkLend and Lista with the Yield.xyz integration. ## Overview Portal integrates with [Yield.xyz Borrow](https://docs.yield.xyz/reference/positionscontroller_getpositions_v1) so your users can supply collateral, borrow, repay and withdraw on leading lending protocols (Aave V3, Morpho Blue, SparkLend and Lista) with their Portal wallets. Portal proxies the Borrow API server-side using the API key you store in the Portal Dashboard, so the key never ships in your app. Borrowing uses the same Yield.xyz API key and the same Portal integration as [Yield.xyz](/integrations/Yield/yield-xyz). If you have already enabled Yield.xyz in the Portal Dashboard, the Borrow endpoints below work right away; there is no separate card or key to add. This page covers how lending and borrowing works, the one-time setup in Yield.xyz and the Portal Dashboard, and what the proxied endpoints return. ## How lending and borrowing works A lending market is a pool: suppliers deposit an asset and earn its **supply APY**; borrowers draw that asset out and pay the **borrow APY**. To borrow, a user first **supplies collateral** and can then borrow up to the market's `maxLtv` of that collateral's USD value (for example 75% for USDC on Aave V3 Base: supply \$100, borrow up to \$75). Same-asset positions are allowed: supply USDC, borrow USDC. There is no term and no repayment schedule. Debt accrues interest continuously, and the position stays open until the user repays (`repay`, or `repayAll: true` to clear it) and withdraws. The only enforcement is **liquidation**: each position has a `healthFactor` (collateral value × `liquidationThreshold` ÷ debt). While it stays above `1` nothing happens; if accruing interest or a drop in the collateral's price pushes it below `1`, anyone can repay part of the debt and take the equivalent collateral plus a bonus (`liquidationPenalty`, typically 5-7.5%). Liquidations are partial, so the user keeps the remaining collateral. With stablecoin collateral this takes years of interest; with volatile collateral it can happen within hours of a price move. Every `POST /actions` response includes `metadata.predictedHealthFactor` and `metadata.predictedLtv`. Show them before the user signs, and treat a predicted health factor near `1` as a hard stop in your UI. ### Why offer it * **Liquidity without selling.** Users who hold ETH, BTC or a yield-bearing token can borrow stablecoins against it to spend or invest without a taxable sale and without giving up upside, the on-chain equivalent of a margin loan or a securities-backed line of credit. * **Keeps balances in your product.** Without it, a user who wants leverage or liquidity withdraws to a DeFi front-end. With it, collateral and debt live in the wallet you already manage, and you can add your own spread or origination fee (markets expose `originationFeeBps` / `feeWrapperAddress`). * **Same integration as earning.** Yield.xyz's lending markets are the other side of the yield opportunities you may already offer; it is one API key, one dashboard toggle and one set of Portal endpoints. ## Steps to enable borrowing ### 1. Enable lending markets in Yield.xyz * Create or access your Yield.xyz account [here](https://dashboard.yield.xyz/sign-up/register-interest?referred_by=portal). * In the Yield.xyz dashboard, enable the lending markets you want to offer (for example Aave V3 on Base). Only enabled markets are returned by `GET /markets` by default; pass `scope=all` to browse every market Yield.xyz supports. * Generate an API key per Portal environment if you have not already (`Development` and `Production`). The same key is used for Yield.xyz's Yield and Borrow APIs. ### 2. Enable the Yield.xyz integration in Portal 1. Open the [Portal Dashboard](https://app.portalhq.io). 2. Navigate to the `Integrations` page and find `Yield.xyz` under `Yield`. It is one integration that covers both yield and lending & borrowing. 3. Add it and paste your Yield.xyz API key for the selected Portal environment, then save. Portal validates the key against Yield.xyz when you save it. Once saved, Portal proxies your SDK/API calls to Yield.xyz Borrow using this key from Portal's servers. Integrations are environment-scoped. Repeat these steps for each Portal environment (for example Development and Production). Yield.xyz Borrow integrations (Aave V3, Morpho Blue, SparkLend, Lista) are live on mainnets only; there are no Sepolia or other EVM testnet markets. Test with small amounts on an inexpensive chain such as Base. ## Endpoints All endpoints live under `https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz-borrow` and accept a Client API Key or Client Session Token as a bearer token. | Method | Path | What it does | | ------ | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/integrations` | List lending/borrowing integrations (Aave V3, Morpho Blue, SparkLend, Lista) with supported networks and actions | | `GET` | `/integrations/{integrationId}` | Details for one integration, including the argument schema for each action | | `GET` | `/markets` | Paginated lending markets with rates, LTVs, liquidation thresholds and liquidity | | `GET` | `/markets/{marketId}` | One market | | `GET` | `/positions` | Supply and debt balances, health factor and LTV for an address on an integration + network | | `GET` | `/positions/liquidations` | Paginated liquidation history for an address (Morpho Blue integrations) | | `POST` | `/actions` | Build unsigned transaction(s) for `supply`, `borrow`, `repay`, `withdraw`, `enableCollateral`, `disableCollateral` or `supplyAndBorrow` | | `GET` | `/actions` | Paginated actions for an address | | `GET` | `/actions/{id}` | One action with its transactions and step progress | | `POST` | `/actions/{id}/step` | Next transaction(s) of a multi-step action (when `hasNextStep` is `true`) | | `POST` | `/transactions/{transactionId}/submit` | Submit a signed payload for Yield.xyz to broadcast, or report a transaction hash you broadcast yourself | | `GET` | `/health` | Yield.xyz Borrow API health | Every success response is wrapped as `{ "data": ... }`. ### Typical flow 1. `GET /integrations` and `GET /markets?integrationId=aave-borrow&network=eip155:8453` to discover what your user can do. 2. `POST /actions` with the wallet address, the action and its `args` (always includes `marketId`, copied exactly as `GET /markets` returned it; Aave `supply`/`withdraw` also need `tokenAddress`). The response contains one or more `transactions` with a `signablePayload`. 3. Sign each transaction with the Portal SDK or Enclave MPC API, then `POST /transactions/{transactionId}/submit` with the `signedPayload` (or the `transactionHash` if you broadcast it yourself). 4. If the action has `hasNextStep: true`, wait for confirmation and call `POST /actions/{id}/step` for the next transaction(s). 5. `GET /positions` to show balances, health factor and available borrow. ```bash theme={null} # 1. Build a supply transaction curl --request POST \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz-borrow/actions' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' \ --header 'Content-Type: application/json' \ --data '{ "integrationId": "aave-borrow", "action": "supply", "address": "0x1234...", "args": { "marketId": "aave-borrow-base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", "tokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "amount": "100" } }' # 2. Read the position back curl --request GET \ --url 'https://api.portalhq.io/api/v3/clients/me/integrations/yield-xyz-borrow/positions?integrationId=aave-borrow&network=eip155:8453&address=0x1234...' \ --header 'Authorization: Bearer [clientApiKey|clientSessionToken]' ``` ## Chain identifiers Portal uses [CAIP-2 chain ids](/resources/chain-id-formatting) everywhere. Pass `network` as CAIP-2 (for example `eip155:1`, `eip155:8453`, `eip155:42161`) and Portal translates it to the Yield.xyz network name; `network` values in responses are translated back to CAIP-2. Requests for a chain Portal does not support are rejected with a `400` before they reach Yield.xyz. Vendor discovery responses (for example an integration's `networks` list) can include chains Portal does not support; those keep the Yield.xyz network name so you can still recognize them. ## Errors Yield.xyz Borrow errors are returned as Portal errors: * `400`: invalid request (the message names the field), an unsupported chain id, a request Yield.xyz rejected (its message is passed through), or a Yield.xyz API key that is missing or was rejected * `404`: unknown market, integration, action or transaction id * `429`: Yield.xyz rate limit hit; back off and retry * `503`: Yield.xyz is unreachable or failed upstream * `500` with `id: INTEGRATION_RESPONSE_SCHEMA_DRIFT`: Yield.xyz changed a response shape and Portal did not forward it. Treat it as a temporary outage of that feature; see [Error codes](/resources/error-codes) Response enumerations (action and transaction statuses, action types) may gain values over time; treat values you do not recognize as opaque rather than failing. ## Guides * [Lend and borrow with Yield.xyz](/apis/enclave-mpc/guide/yield-xyz-borrow) using the Enclave MPC API ## API reference Open the interactive Yield.xyz Borrow endpoints reference. Need some advice on legal disclaimers when offering borrowing? Check out [Yield.xyz's disclaimer guidelines here](https://stakekit.notion.site/Yield-Disclaimer-Index-2a35318e934e806f863fecea64be38a8). # Integrations Overview Source: https://docs.portalhq.io/integrations/index Comprehensive index of all Portal integrations organized by category with SDK implementation guides ## Overview Portal offers a growing ecosystem of integrations that enhance your application's capabilities across on/off ramp, security, trading, and yield (including lending & borrowing). All integrations are configured through the [Portal Dashboard](https://app.portalhq.io) and accessed securely via Portal's APIs and SDKs. All integrations are environment-scoped. You'll need to configure them separately for Development and Production environments. ## Integration Categories ### On / Off Ramp Enable virtual bank accounts and global payouts with hosted compliance checks and payout orchestration. Run KYC onboarding, payins, and global payout flows. **Key Features:** * Hosted onboarding for KYC/KYB * Bank-deposit payins with fiat-to-stablecoin settlement * Stablecoin-to-fiat payouts via local payment methods * Workflow and status tracking through Portal API endpoints ### Security Protect your users with industry-leading security scanning solutions that analyze transactions, addresses, tokens, NFTs, and URLs for potential threats. Protect your wallets from hacks and scams. Protect your wallets from hacks and scams. **Key Features:** * Pre-transaction security scanning * Optional integrated scans (automatically scan all transactions before signing) * Support for EVM, Solana, and TRON (mainnet) networks * Address, token, NFT, and URL scanning capabilities ### Trading Execute token swaps and cross-chain bridges with leading DeFi protocols, all managed through Portal's unified interface. Swap and trade tokens with your wallets. Swap and bridge tokens with your wallets. **Key Features:** * Best price execution across multiple DEXs * Multi-chain support * Bridge assets across different blockchains * Slippage protection ### Yield Discover and manage yield-generating opportunities across DeFi protocols with automated position management, and let your users supply collateral and borrow against it on leading lending protocols. One Yield.xyz API key covers both. Integrates 1,000+ yield opportunities directly into your wallets. Supply, borrow, repay and withdraw on Aave V3, Morpho Blue, SparkLend and Lista markets. **Key Features:** * Access to staking, lending, and liquidity pool opportunities * Position tracking and history * Automated yield discovery * Multi-protocol support * Lending markets with rates, LTVs and liquidation thresholds; supply / borrow / repay / withdraw transactions for your wallets to sign * Position tracking with health factors and liquidation history per address ## Getting Started ### Step 1: Choose Your Integration Select the integration(s) that best fit your application's needs from the categories above. ### Step 2: Configure in Portal Dashboard 1. Navigate to the [Portal Dashboard](https://app.portalhq.io) 2. Go to the **Integrations** page 3. Locate your desired integration and click **Add** 4. Follow the integration-specific setup instructions 5. Paste your API keys from the provider 6. (Optional) Enable additional features like integrated scans ### Step 3: Implement in Your Application Each integration includes comprehensive SDK guides for all supported platforms: * **Enclave MPC API** - Server-side implementation * **Web SDK** - Browser-based applications * **iOS SDK** - Native iOS apps * **Android SDK** - Native Android apps * **React Native SDK** - Cross-platform mobile apps * **Flutter SDK** - Cross-platform with Flutter Visit each integration's documentation page for platform-specific implementation guides. ## Support Need help with integrations? * **Documentation**: Visit each integration's specific documentation page * **Community**: Join our [Slack community](https://join.slack.com/t/portalcommunity/shared_invite/zt-1zlyexf43-A94Z4ugntrgLGs2j5slmeQ) * **Dashboard**: Manage all integrations in the [Portal Dashboard](https://app.portalhq.io) Looking for a specific integration? Let us know in our [Slack community](https://join.slack.com/t/portalcommunity/shared_invite/zt-1zlyexf43-A94Z4ugntrgLGs2j5slmeQ) and we'll consider adding it to our roadmap. # Account abstraction Source: https://docs.portalhq.io/resources/account-abstraction This guide will walk you through the process of configuring Account Abstraction for your organization and your users. This document serves as a comprehensive guide for enabling and configuring Account Abstraction (AA) within your organization. This currently allows your organization to sponsor gas fees for your clients, using specified policies and chains. The Account Abstraction feature is available on all mobile and web SDKs. While AA clients can make Tron/Stellar/etc sign requests, these transactions are not gas subsidized. This is because AA clients on Tron/Stellar/etc utilize a standard MPC wallet under the hood, which doesn't support gas sponsorship in the same way as EVM-based chains. ## Supported Networks This list shows the blockchain networks that Portal supports for account abstraction. | Chain | Chain ID | Type | | ------------------------- | -------------------- | ------- | | Ethereum | `eip155:1` | Mainnet | | Ethereum Goerli | `eip155:5` | Testnet | | Optimism Mainnet | `eip155:10` | Mainnet | | BSC Mainnet | `eip155:56` | Mainnet | | Gnosis Mainnet | `eip155:100` | Mainnet | | Polygon Mainnet | `eip155:137` | Mainnet | | opBNB Mainnet | `eip155:204` | Mainnet | | Optimism Goerli Testnet | `eip155:420` | Testnet | | Astar ZKEvm | `eip155:3776` | Mainnet | | Cyber Mainnet | `eip155:7560` | Mainnet | | Base Mainnet | `eip155:8453` | Mainnet | | Gelato OP Testnet | `eip155:42069` | Testnet | | Arbitrum One Mainnet | `eip155:42161` | Mainnet | | Arbitrum Nova | `eip155:42170` | Testnet | | Celo | `eip155:42220` | Mainnet | | Avalanche Fuji Testnet | `eip155:43113` | Testnet | | Avalanche C-Chain Mainnet | `eip155:43114` | Mainnet | | Celo Alfajores Testnet | `eip155:44787` | Testnet | | Linea Testnet | `eip155:59140` | Testnet | | Linea Mainnet | `eip155:59144` | Mainnet | | Polygon Mumbai Testnet | `eip155:80001` | Testnet | | Polygon Amoy Testnet | `eip155:80002` | Testnet | | Blast | `eip155:81457` | Mainnet | | Base Goerli Testnet | `eip155:84531` | Testnet | | Base Sepolia Testnet | `eip155:84532` | Testnet | | Arbitrum Goerli Testnet | `eip155:421613` | Testnet | | Arbitrum Sepolia Testnet | `eip155:421614` | Testnet | | Astar ZKatana Testnet | `eip155:1261120` | Testnet | | Astar zKyoto Testnet | `eip155:6038361` | Testnet | | Ethereum Sepolia | `eip155:11155111` | Testnet | | Optimism Sepolia Testnet | `eip155:11155420` | Testnet | | Polygon Blackberry | `eip155:94204209` | Testnet | | Cyber Testnet | `eip155:111557560` | Testnet | | OP Celestia Raspberry | `eip155:123420111` | Testnet | | Blast Sepolia | `eip155:168587773` | Testnet | | Degen Mainnet | `eip155:666666666` | Mainnet | | Arbitrum Blueberry | `eip155:88153591557` | Testnet | For Solana networks, account abstraction is supported through the use of the `feePayer` field in transactions. If you set a policy for a mainnet chain, your account will be charged for gas subsidization even if you are on your Portal Development environment. Please set policies for Testnet chains while you are still building or testing to avoid unnecessary charges. ## Generating smart contract wallets for your users Generating smart contract wallets for your users is incredibly easy with Portal. ### Step 1: Get access to the feature Reach out to the Portal team to request access. ### Step 2: Create Clients with Account Abstraction After Portal enables Account Abstraction for your organization, you are ready to create your first clients. This can be achieved by hitting [the **Create a new client** endpoint](../apis/custodian/reference#create-a-new-client) with this as the request body: ```json theme={null} { "isAccountAbstracted": true } ``` ### Step 3: Generate a Smart Contract Address! Now that you have Account Abstraction enabled for your client, you can [use the SDKs as you normally would](../sdks/react-native/guide/create-a-wallet) to create a wallet, back it up, recover it, and sign. Since Account Abstraction is now enabled for the client, the address returned after creating a wallet is a smart contract address. With the completion of the above steps, you would have successfully created a client with Account Abstraction. Congratulations! :tada: You can now proceed to use the Portal SDKs and services as usual. The client's smart contract wallet is only deployed upon their first successful **`eth_sendTransaction`** request. The hash returned when signing or sending with an Account Abstraction client is a [UserOperation hash](https://eips.ethereum.org/EIPS/eip-4337), **not** an on-chain transaction hash — it will not resolve on a block explorer such as Etherscan or Monadscan. The on-chain transaction hash is assigned once the bundler includes the UserOperation on-chain; look up the UserOperation hash on a UserOp explorer such as [JiffyScan](https://jiffyscan.xyz/) to find it. Clients using Account Abstraction require dApps they interact with to support [EIP-1271](https://eips.ethereum.org/EIPS/eip-1271) for verifying signed messages. ## Configure Gas Subsidization Policy ### Step 1: Modify Gas Subsidization Policy In order to configure your Account Abstraction settings, log into [the Portal Admin Dashboard](https://app.portalhq.io). Navigate to the **Settings** page and scroll down to find the **Account Abstraction Configuration** section. In the section, you will find multiple rows corresponding to various chains for which you can set gas subsidization policies. To modify a policy, simply click on **Edit Policy** for the desired chain. ### Step 2: Confirm Policy Configuration Once you have made your desired changes to the gas subsidization policies, ensure you verify and confirm the configurations. Contact our team for more information on billing. Congratulations! 🎉 You will now be sponsoring the gas fees for the client, based on the policies you have set and the chain the client is using. ## Controlling Gas Sponsorship Per Transaction The `sponsorGas` parameter allows you to opt out of gas sponsorship on a per-transaction basis when using Account Abstraction clients. ### Parameter Behavior | Value | Behavior | | --------------- | ------------------------------------------------------------------------------------------------------------- | | Unset (default) | Portal sponsors the transaction if AA is enabled and the chain is configured in your policies | | `true` | Portal sponsors the transaction if AA is enabled and the chain is configured in your policies (same as unset) | | `false` | Portal does **not** sponsor; the Portal client pays transaction fees | Setting `sponsorGas: true` or leaving it unset produces the same behavior - both will sponsor gas if your environment is configured for AA on that chain. Only `sponsorGas: false` changes the default behavior to disable sponsorship. ### When to Use This Parameter The primary use case for `sponsorGas` is to **disable sponsorship** when you want users to pay for their own gas fees: * **`sponsorGas: false`**: Use when users should pay for their own gas, such as for testing, specific transaction types, or when you want to ensure the user has sufficient funds * **Omit or `sponsorGas: true`**: Default behavior - Portal sponsors if AA is configured ### Example: Disable Gas Sponsorship ```bash theme={null} curl --request POST \ --url https://mpc-client.portalhq.io/v1/assets/send \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "share": "eyJjbG...", "chain": "eip155:11155111", "to": "0xdFd8302f44727A6348F702fF7B594f127dE3A902", "token": "NATIVE", "amount": "0.0001", "rpcUrl": "https://api.portalhq.io/rpc/v1/eip155/11155111", "sponsorGas": false }' ``` Gas sponsorship only occurs if your environment is configured for Account Abstraction on the specific chain. The `sponsorGas` parameter only controls whether to opt out of sponsorship - it cannot force sponsorship if AA is not configured. The `sponsorGas` parameter is available on both the [`/v1/assets/send`](../apis/enclave-mpc/reference#send-the-clients-assets) and [`/v1/sign`](../apis/enclave-mpc/reference#sign-a-transaction-or-message-by-chain) endpoints. ## Batch User Operations The Web and React Native SDKs both provide dedicated helpers for building, signing, and broadcasting ERC-4337 batch UserOperations: `sendBatchUserOp`, `sendBatchedAssets`, `buildBatchedUserOp`, and `broadcastBatchedUserOp`. See the [Web batch user operations guide](../sdks/web/guide/batch-user-operations) or the [React Native batch user operations guide](../sdks/react-native/guide/batch-user-operations) for full documentation. ## Support If you encounter any issues or have questions about the Account Abstraction feature, feel free to reach out to our support team. # Agentic wallets Source: https://docs.portalhq.io/resources/agentic-wallets Use Portal's local MCP extension or CLI to create and manage a Portal MPC wallet from Claude Desktop, Antigravity, or another local agent. Portal Agentic Wallets let a user create and manage a Portal MPC wallet from an AI assistant. The V1 experience uses a local MCP extension or CLI, a human activation step in the Agentic app, and Portal's Enclave MPC API for wallet operations. Agentic wallets are currently in private beta. You need an invite code to activate a wallet. Use small amounts while testing signing and sending flows. ## How it works The Agentic setup flow keeps wallet approval human-controlled while letting the assistant or local CLI handle the mechanical setup work. 1. Install the Portal extension for Claude Desktop, install the Antigravity plugin, or download the `portal-wallet` CLI. 2. Ask your assistant to create a Portal wallet, or run `portal-wallet create --wait`. 3. The local tool starts a Portal Agentic bootstrap request. 4. Portal returns an activation link. 5. Open the link at `https://agentic-app.portalhq.io`, enter your invite code and email, then verify the magic link. 6. The local tool completes wallet creation and returns the wallet address. Example prompt: ```text theme={null} Create a new Portal wallet and share the address with me. ``` ## Downloads Download the current artifacts from the [Portal Agentic MCP v0.1.2 release](https://github.com/portal-hq/portal-agentic-mcp/releases/tag/v0.1.2). | Client | Platform | Download | | ------------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Claude Desktop extension | macOS Apple Silicon | [`portal-agentic-mcp-0.1.2-darwin-arm64.mcpb`](https://github.com/portal-hq/portal-agentic-mcp/releases/download/v0.1.2/portal-agentic-mcp-0.1.2-darwin-arm64.mcpb) | | Antigravity CLI plugin | macOS Apple Silicon | [`portal-wallet-antigravity-0.1.2-darwin-arm64.tar.gz`](https://github.com/portal-hq/portal-agentic-mcp/releases/download/v0.1.2/portal-wallet-antigravity-0.1.2-darwin-arm64.tar.gz) | | Portal Wallet CLI | macOS Apple Silicon | [`portal-wallet-0.1.2-darwin-arm64.tar.gz`](https://github.com/portal-hq/portal-agentic-mcp/releases/download/v0.1.2/portal-wallet-0.1.2-darwin-arm64.tar.gz) | | Checksums | All artifacts | [`SHA256SUMS`](https://github.com/portal-hq/portal-agentic-mcp/releases/download/v0.1.2/SHA256SUMS) | You can verify downloaded files with the `SHA256SUMS` file in the same release. ## Install in Claude Desktop 1. Open Claude Desktop settings. 2. Go to **Extensions**. 3. Open **Advanced settings**. 4. Click **Install Extension**. 5. Select the downloaded `.mcpb` file. Restart Claude Desktop after installing or updating the extension. ## Install in Antigravity CLI Download and extract the Antigravity plugin: ```bash theme={null} tar -xzf portal-wallet-antigravity-0.1.2-darwin-arm64.tar.gz -C /tmp ``` Install the extracted plugin: ```bash theme={null} agy plugin install /tmp/portal-wallet-antigravity-0.1.2-darwin-arm64 ``` Restart Antigravity CLI after installing or updating the plugin. Use the MCP server list in Antigravity to confirm that `portal-wallet` is available. ## Use the Portal Wallet CLI Download and extract the CLI: ```bash theme={null} tar -xzf portal-wallet-0.1.2-darwin-arm64.tar.gz ``` Run the CLI from the extracted directory: ```bash theme={null} ./portal-wallet-0.1.2-darwin-arm64/bin/portal-wallet status ./portal-wallet-0.1.2-darwin-arm64/bin/portal-wallet create --wait ``` You can move the `portal-wallet` binary onto your `PATH` if you want to run it from any directory. Common commands: ```bash theme={null} portal-wallet status portal-wallet create --wait portal-wallet addresses portal-wallet assets --chain eip155:11155111 portal-wallet disconnect --yes ``` Use `--json` for automation or agents that can run local shell commands: ```bash theme={null} portal-wallet --json status ``` The CLI shares the same OS credential storage and local metadata model as the MCP extensions. ### Use the CLI from ChatGPT or another local agent ChatGPT web does not directly install local MCP servers or run local binaries. To use Portal Wallet with ChatGPT, run ChatGPT in a local agent environment that can execute shell commands, or ask ChatGPT for commands and run them yourself. When your local agent can run shell commands, point it at the installed `portal-wallet` binary and ask it to prefer JSON output: ```text theme={null} Use the local portal-wallet CLI to create and manage my Portal Agentic wallet. Prefer --json output when reading wallet status, addresses, balances, or command results. Do not ask me to paste Portal API keys, session tokens, or MPC share data. Before signing, sending assets, or making x402 paid requests, ask me to confirm the exact action. ``` Useful commands for a local agent: ```bash theme={null} portal-wallet --json status portal-wallet create --wait portal-wallet --json addresses portal-wallet --json assets --chain eip155:11155111 ``` For state-changing actions, the CLI requires confirmation by default. A local agent should only pass `--yes` after the user explicitly confirms the action. ## Security model The Agentic MCP extension and CLI run on the user's device and communicate with Portal APIs only when a tool needs to create, read, sign, send, or pay. | Item | Where it lives | | --------------------------- | ------------------------------------------- | | Portal API credentials | OS credential storage on the user's device | | MPC client share data | OS credential storage on the user's device | | Temporary bootstrap state | Local app metadata on the user's device | | Agentic activation approval | Portal Agentic app after email verification | ## What your assistant can do After setup, your assistant can use the local extension or CLI to: * Create or resume wallet setup. * Retrieve wallet addresses and balances. * Sign messages or transactions. * Send assets when the user explicitly asks for a transfer. * Make bounded x402 paid requests when the requested payment is within the user-supplied maximum amount. * Disconnect and clear local Portal Agentic state. The extension exposes these capabilities through MCP tools, but most users should not need to call individual tools manually. Ask your assistant for the wallet action you want, and it can choose the relevant tool. ## Troubleshooting ### Claude Desktop does not see the Portal tools Restart Claude Desktop after installing the `.mcpb` extension. If the tools still do not appear, remove and reinstall the extension. ### Antigravity does not see the Portal MCP server Restart Antigravity CLI after installing the plugin. If the MCP server still does not appear, confirm the Antigravity MCP config points at the staged plugin binary: ```json theme={null} { "mcpServers": { "portal-wallet": { "command": "sh", "args": [ "-c", "$HOME/.gemini/config/plugins/portal-wallet/bin/portal-wallet mcp" ] } } } ``` ### The `portal-wallet` command is not found Run the binary from the extracted directory, or move it onto your `PATH`. ### ChatGPT cannot run `portal-wallet` ChatGPT web cannot execute local shell commands on its own. Use a local agent environment with shell access, or have ChatGPT provide the command for you to run manually in your terminal. ### The invite code fails Invite codes are single-use and expire. Request a new invite code if the code was already used, expired, or copied incorrectly. ### The assistant stops after activation Ask the assistant to continue creating the Portal wallet, or run `portal-wallet create --wait`. The local tool can resume pending bootstrap state and finish wallet creation after email verification. ### A wallet already exists locally Use the disconnect action only if you intentionally want to remove local Portal Agentic credentials and wallet share data from this device. Disconnecting local state does not delete onchain funds. ## Related docs * [API quickstart](/apis/quickstart) * [Enclave MPC API guide](/apis/enclave-mpc/guide/getting-started) * [Chain ID formatting](/resources/chain-id-formatting) * [Testnet faucets](/resources/testnet-faucets) # Alert Webhooks Source: https://docs.portalhq.io/resources/alert-webhooks Get realtime wallet notifications for your Portal clients. ## What's an alert webhook? Alert webhooks can send you realtime wallet notifications for your clients. For example, they can be useful for receiving notifications when your clients receive or send EVM transactions. Alert webhooks are easily configured in the Portal Admin Dashboard. If you don't see "Alert Webhooks" in the Portal Admin Dashboard, reach out to our team and we can enable the feature for you. ### Configuring alert webhooks 1. Reach out to our support team and we can enable alert webhooks for your organization. 2. Navigate to `"Configuration"` > `"Webhooks"` in the [Portal Admin Dashboard](https://app.portalhq.io) and under the `"Alert Webhooks"` section, click `"New +"`. 3. Enter your alert webhook URL. 4. Select the events you want to receive (e.g. `EVM Wallet Transactions`). 5. Save your configuration. Take note of the IP addresses listed on this modal. You can add them to your allowlist to ensure you're only accepting requests from Portal. ### External addresses External addresses let you receive alert webhook notifications for blockchain addresses that weren't generated by Portal — such as treasury wallets, exchange deposit addresses, or other addresses you want to monitor. Portal supports external addresses on **EVM (EIP-155)** and **Solana** namespaces. #### Adding external addresses via the Dashboard 1. Navigate to `"Configuration"` > `"Webhooks"` in the [Portal Admin Dashboard](https://app.portalhq.io). 2. Below the `"Alert Webhooks"` section, find the `"External Addresses"` section. 3. Click `"Add"`. 4. Select a namespace (`eip155` or `solana`). 5. Enter the address you want to monitor. #### Adding external addresses via the API You can also manage external addresses programmatically using the Custodian API: * **Create:** `POST /custodians/me/alerts/webhooks/external-addresses` * **List:** `GET /custodians/me/alerts/webhooks/external-addresses` * **Delete:** `DELETE /custodians/me/alerts/webhooks/external-addresses/{externalAddressId}` The request body for creating an external address requires an `address` and a `namespace`: ```json theme={null} { "address": "0x1234...", "namespace": "eip155" } ``` For Solana addresses, use `"solana"` as the namespace: ```json theme={null} { "address": "ABC123...", "namespace": "solana" } ``` Addresses are validated for correct format based on the selected namespace. Blackhole addresses (such as null or dead addresses) are rejected. ## Dual events for internal transfers When **both** the sender and the receiver of an on-chain transfer are addresses you've registered with Portal — for example, two of your Portal clients, or a Portal client transferring to one of your [external addresses](#external-addresses) — a single on-chain transfer produces **two webhook events**, not one: * An **`OUTBOUND`** event from the sender's perspective. * An **`INBOUND`** event from the receiver's perspective. Both events describe the same on-chain movement, so the `from` and `to` fields are **identical between the two events**. These fields reflect who actually sent and who actually received funds on-chain — they are *not* relative to the subscribed wallet that triggered the event. To tell the two events apart, use: * **`direction`** — `OUTBOUND` means the event is from the sender's perspective, `INBOUND` means it's from the receiver's perspective. On `Solana Delegated Transfers` a third value, `DELEGATED`, can also appear: it marks the owner whose tokens were moved by a delegate (the source of the funds) — an address that isn't directly executing the transaction. See [Direction](#direction). * **`triggeredBy`** — the subscribed address that caused this particular event to be emitted. For EVM events, use `data[].metadata.triggeredBy`. For Solana events, use `data.rawEvents[].triggeredBy`. For the `OUTBOUND` event this matches the sender; for the `INBOUND` event it matches the receiver; for a `DELEGATED` event it matches the owner whose funds were moved. If only one side of the transfer is registered with Portal, you'll receive a **single** event for that side. The dual-event behavior only happens when both sender and receiver are registered. ### Example: a single transfer producing two events The example below shows a single `1 USDC` transfer on Ethereum Sepolia from `0x04e15c...14e4` to `0x14cea4...75f0`, where **both** addresses are registered with Portal. Notice that `from`, `to`, `amount`, `transactionHash`, and `blockNumber` are identical across the two events; only `direction` and `data[].metadata.triggeredBy` differ. ```json theme={null} { "data": [ { "to": "0x14cea4af1ad06721ad4ae04ab1233e25e63575f0", "from": "0x04e15c61b2a9c8c54f29c9bbcb83c3e3826514e4", "amount": "1", "chainId": "eip155:11155111", "assetType": "NON_NATIVE_TOKEN", "chainName": "sepolia", "direction": "OUTBOUND", "tokenSymbol": "USDC", "metadata": { "sentAt": "2025-03-17T18:32:00.000Z", "confirmed": true, "rawAmount": "1000000", "tokenName": "USDC", "blockNumber": "12345678", "triggeredBy": "0x04e15c61b2a9c8c54f29c9bbcb83c3e3826514e4", "tokenAddress": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", "tokenDecimals": 6, "transactionHash": "0xabc123def4567890abc123def4567890abc123def4567890abc123def4567890", "userOperationHash": null } } ], "metadata": { "custodianId": "custodianId", "environmentId": "environmentId" }, "type": "EIP_155_TX_V1" } ``` ```json theme={null} { "data": [ { "to": "0x14cea4af1ad06721ad4ae04ab1233e25e63575f0", "from": "0x04e15c61b2a9c8c54f29c9bbcb83c3e3826514e4", "amount": "1", "chainId": "eip155:11155111", "assetType": "NON_NATIVE_TOKEN", "chainName": "sepolia", "direction": "INBOUND", "tokenSymbol": "USDC", "metadata": { "sentAt": "2025-03-17T18:32:00.000Z", "confirmed": true, "rawAmount": "1000000", "tokenName": "USDC", "blockNumber": "12345678", "triggeredBy": "0x14cea4af1ad06721ad4ae04ab1233e25e63575f0", "tokenAddress": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", "tokenDecimals": 6, "transactionHash": "0xabc123def4567890abc123def4567890abc123def4567890abc123def4567890", "userOperationHash": null } } ], "metadata": { "custodianId": "custodianId", "environmentId": "environmentId" }, "type": "EIP_155_TX_V1" } ``` ### Correlating or deduplicating dual events If your application treats internal transfers as a single business-level "movement" rather than two independent events, correlate the two events using fields that come from the underlying on-chain transaction: | Event type | Correlation key | | ---------------------------- | ----------------------------------------------------------------- | | `EVM Wallet Transactions` | `data[].metadata.transactionHash` + `data[].metadata.blockNumber` | | `Solana Wallet Transactions` | `data.rawEvents[].signature` | Two events sharing the same correlation key represent the same on-chain transfer. Use `direction` and `triggeredBy` to know which subscribed wallet each event was emitted for. For EVM events, use `data[].metadata.triggeredBy`; for Solana events, use `data.rawEvents[].triggeredBy`. This dual-event behavior applies to **EVM Wallet Transactions** and **Solana Wallet Transactions**. It also applies to token approvals, revocations, and delegated transfers when the subscribed addresses are on both sides of the operation. ## Delivery event lifecycle Each time Portal sends an alert webhook to your URL, the attempt is recorded as a **delivery event**. You can inspect every delivery event for a given alert webhook with [`GET /custodians/me/alerts/webhooks/{alertWebhookId}/delivery-events`](/api-reference/alert-webhooks/list-alert-webhook-delivery-events) and manually retry a single delivery event with [`POST /custodians/me/alerts/webhooks/{alertWebhookId}/delivery-events/{deliveryEventId}/retries`](/api-reference/alert-webhooks/retry-an-alert-webhook-delivery-event). A delivery event always has one of four statuses: | Status | Meaning | | ----------- | ------------------------------------------------------------------------------------------------------------- | | `PENDING` | The event was created and is waiting for Portal to deliver it (or to be retried by the automatic retry loop). | | `DELIVERED` | Your alert webhook URL returned a `2XX` response on the latest attempt. | | `FAILED` | Automatic retries were exhausted without a `2XX` response, or a manual retry failed. | | `REPLAYING` | A manual retry is currently in flight (via the `/retries` endpoint). | The lifecycle looks like this: ```mermaid theme={null} stateDiagram-v2 [*] --> PENDING PENDING --> DELIVERED PENDING --> FAILED PENDING --> REPLAYING FAILED --> REPLAYING REPLAYING --> DELIVERED REPLAYING --> FAILED DELIVERED --> [*] ``` ### Manual retries replace the automatic retry loop The automatic retry handler only processes events whose status is `PENDING`. When you call the `/retries` endpoint on an event, Portal immediately flips its status to `REPLAYING` and enqueues a **single-shot** delivery attempt on a dedicated retry queue. This has an important consequence: If you retry a delivery event that is still `PENDING`, the automatic retry loop will **stop** retrying it on its next attempt — because the event is no longer in the `PENDING` state. The manual retry becomes the only remaining attempt; whether it succeeds or fails, the event will move to `DELIVERED` or `FAILED` and stay there. In practice this means: * Retrying a `FAILED` event is safe and idempotent — there is no automatic retry running in parallel. * Retrying a `PENDING` event short-circuits the remaining scheduled retries. Only do this when you want to force an immediate attempt instead of waiting for the next backoff window. * Retrying a `REPLAYING` event is a no-op until the in-flight retry completes. * Retrying a `DELIVERED` event will create another delivery attempt, but most integrations should treat already-delivered events as terminal. ## Types of alerts ### `Wallet Created` Once you configure an alert webhook with `Wallet Created` selected as an event, Portal will send you a notification whenever one of your Portal clients successfully creates a wallet and stores its signing share. Portal makes a `POST` request to your configured alert webhook URL with a request body that contains the `clientId`, the `walletId`, the `signingSharePairId`, and the `curve` of the new wallet. Each Portal client has a key for each of two curves (`SECP256K1` and `ED25519`), so you will receive one `Wallet Created` event per curve — two per client, each carrying that curve's `walletId` (the `clientId` is the same for both). **Retries:** When your webhook does not respond to a request, or if you do not respond with a `2XX` status code, Portal will retry the request up to 9 times with exponential backoff (see the [retry schedule](#retry-schedule)). If no successful response occurs after all retries, the webhook event will be marked `FAILED` and you can re-drive it with [`POST /custodians/me/alerts/webhooks/{alertWebhookId}/delivery-events/{deliveryEventId}/retries`](/api-reference/alert-webhooks/retry-an-alert-webhook-delivery-event). See [Delivery event lifecycle](#delivery-event-lifecycle) for details on how manual retries interact with the automatic retry loop. **Headers:** | Name | Type | Description | | ------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Content-Type` | String | `application/json` | | `X-WEBHOOK-SECRET` | String | You can find the alert webhook secret in the [Portal Admin Dashboard](https://app.portalhq.io) on the `"Webhooks"` page under the `"Alert Webhooks"` section. | **Request body (examples):** ```json theme={null} { "data": { "clientId": "clientId", "walletId": "walletId", "signingSharePairId": "signingSharePairId", "curve": "SECP256K1" // SECP256K1 or ED25519, or null if unavailable. }, "metadata": { "custodianId": "custodianId", "environmentId": "environmentId", }, "type": "WALLET_GENERATE_V1" } ``` ### `Wallet Backed Up` Once you configure an alert webhook with `Wallet Backed Up` selected as an event, Portal will send you a notification whenever one of your Portal clients successfully backs up a wallet key. Portal makes a `POST` request to your configured alert webhook URL with a request body that contains the `clientId`, the `walletId`, the `curve`, the `backupMethod` used, and the `backupSharePairId` that was stored. Each Portal client has a key for each of two curves (`SECP256K1` and `ED25519`), so you will receive one `Wallet Backed Up` event per curve — two per client, each carrying that curve's `walletId` (the `clientId` is the same for both). If a client backs up with more than one method, you will receive one event per method per curve. **Retries:** When your webhook does not respond to a request, or if you do not respond with a `2XX` status code, Portal will retry the request up to 9 times with exponential backoff (see the [retry schedule](#retry-schedule)). If no successful response occurs after all retries, the webhook event will be marked `FAILED` and you can re-drive it with [`POST /custodians/me/alerts/webhooks/{alertWebhookId}/delivery-events/{deliveryEventId}/retries`](/api-reference/alert-webhooks/retry-an-alert-webhook-delivery-event). See [Delivery event lifecycle](#delivery-event-lifecycle) for details on how manual retries interact with the automatic retry loop. **Headers:** | Name | Type | Description | | ------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Content-Type` | String | `application/json` | | `X-WEBHOOK-SECRET` | String | You can find the alert webhook secret in the [Portal Admin Dashboard](https://app.portalhq.io) on the `"Webhooks"` page under the `"Alert Webhooks"` section. | **Request body (examples):** ```json theme={null} { "data": { "clientId": "clientId", "walletId": "walletId", "curve": "SECP256K1", // SECP256K1 or ED25519, or null if unavailable. "backupMethod": "GDRIVE", // GDRIVE, ICLOUD, PASSWORD, PASSKEY, etc. "backupSharePairId": "backupSharePairId" // The backup share pair stored for this curve. }, "metadata": { "custodianId": "custodianId", "environmentId": "environmentId", }, "type": "WALLET_BACKUP_V1" } ``` ### `Wallet Recovered` Once you configure an alert webhook with `Wallet Recovered` selected as an event, Portal will send you a notification whenever one of your Portal clients successfully recovers a wallet key and stores the recovered signing share. Portal makes a `POST` request to your configured alert webhook URL with a request body that contains the `clientId`, the `walletId`, the new `signingSharePairId`, and the `curve`. Each Portal client has a key for each of two curves (`SECP256K1` and `ED25519`), so you will receive one `Wallet Recovered` event per curve — two per client, each carrying that curve's `walletId` (the `clientId` is the same for both). **Retries:** When your webhook does not respond to a request, or if you do not respond with a `2XX` status code, Portal will retry the request up to 9 times with exponential backoff (see the [retry schedule](#retry-schedule)). If no successful response occurs after all retries, the webhook event will be marked `FAILED` and you can re-drive it with [`POST /custodians/me/alerts/webhooks/{alertWebhookId}/delivery-events/{deliveryEventId}/retries`](/api-reference/alert-webhooks/retry-an-alert-webhook-delivery-event). See [Delivery event lifecycle](#delivery-event-lifecycle) for details on how manual retries interact with the automatic retry loop. **Headers:** | Name | Type | Description | | ------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Content-Type` | String | `application/json` | | `X-WEBHOOK-SECRET` | String | You can find the alert webhook secret in the [Portal Admin Dashboard](https://app.portalhq.io) on the `"Webhooks"` page under the `"Alert Webhooks"` section. | **Request body (examples):** ```json theme={null} { "data": { "clientId": "clientId", "walletId": "walletId", "signingSharePairId": "signingSharePairId", "curve": "SECP256K1" // SECP256K1 or ED25519, or null if unavailable. }, "metadata": { "custodianId": "custodianId", "environmentId": "environmentId", }, "type": "WALLET_RECOVER_V1" } ``` ### `Wallet Ejected` Once you configure an alert webhook with `Wallet Ejected` selected as an event, Portal will send you notifications whenever one of your Portal clients ejects their wallet. When a wallet ejection occurs, Portal makes a `POST` request to your configured alert webhook URL with a request body that contains the `clientId` of the Portal client who ejected their wallet, as well as the `clientPlatform` and `clientPlatformVersion` of the client if available. Ejecting affects a client's entire wallet across both curves at once, so Portal sends a single `Wallet Ejected` event per client — unlike the per-curve `Wallet Created`, `Wallet Backed Up`, and `Wallet Recovered` events above, which fire once per curve. Regardless of your API response, the eject operation has already been processed for the Portal client who ejected their wallet. **Retries:** `Wallet Ejected` events are delivered with a single synchronous attempt and are not automatically retried (see the [retry schedule](#retry-schedule)). If no successful response occurs, the webhook event will be marked `FAILED` and you can re-drive it with [`POST /custodians/me/alerts/webhooks/{alertWebhookId}/delivery-events/{deliveryEventId}/retries`](/api-reference/alert-webhooks/retry-an-alert-webhook-delivery-event). See [Delivery event lifecycle](#delivery-event-lifecycle) for details on how manual retries interact with the automatic retry loop. **Headers:** | Name | Type | Description | | ------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Content-Type` | String | `application/json` | | `X-WEBHOOK-SECRET` | String | You can find the alert webhook secret in the [Portal Admin Dashboard](https://app.portalhq.io) on the `"Webhooks"` page under the `"Alert Webhooks"` section. | **Request body (examples):** ```json theme={null} { "data": { "clientId": "clientId", "clientPlatform": "NATIVE_IOS", // CLI, NATIVE_ANDROID, NATIVE_IOS, REACT_NATIVE, WEB, EMM, or null. "clientPlatformVersion": "1.0.0" // The version of the client platform, or null if not provided. }, "metadata": { "custodianId": "custodianId", "environmentId": "environmentId", }, "type": "WALLET_EJECT_V1" } ``` ### `Signature Approvals` After enabling `Signature Approvals`, Portal will make a request to your configured alert webhook URL any time that one of your Portal wallets attempt to sign. This request will contain the `chainId`, `clientId`, and `signingRequest`, which you can use to derive if the signing request should be allowed to continue. You must respond with a status code of `200-299` for the signing request to continue. To deny the request, you must respond with a `400` status code. **If any other status code is received, or if 30 seconds passes with no response from your API, we will deny the signing request.** If your API does not respond to `Signature Approval` alerts, your Portal clients' signing requests will be denied to ensure Portal only continues processing the signing request when your API gives explicit permission to do so. **Retries:** `Signature Approvals` events are delivered with a single synchronous attempt and are not automatically retried — the signing decision is made from that one response (see the [retry schedule](#retry-schedule)). If no successful response occurs, the webhook event will be marked `FAILED` and you can re-drive it with [`POST /custodians/me/alerts/webhooks/{alertWebhookId}/delivery-events/{deliveryEventId}/retries`](/api-reference/alert-webhooks/retry-an-alert-webhook-delivery-event). Note that a manual retry does not retroactively change the original signing decision. See [Delivery event lifecycle](#delivery-event-lifecycle) for details on how manual retries interact with the automatic retry loop. **Headers:** | Name | Type | Description | | ------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Content-Type` | String | `application/json` | | `X-WEBHOOK-SECRET` | String | You can find the alert webhook secret in the [Portal Admin Dashboard](https://app.portalhq.io) on the `"Webhooks"` page under the `"Alert Webhooks"` section. | **Request body (examples):** ```json theme={null} { "data": { "chainId": "eip155:11155111", // Ethereum Sepolia "clientId": "clientId", "signingRequest": { "method": "eth_sendTransaction", "params": "{\"from\":\"0xec445db8df2208dde9b5ad87e77b6a4d45855d4f\",\"to\":\"0xdFd8302f44727A6348F702fF7B594f127dE3A902\",\"value\":\"0x5af3107a4000\"}" } }, "metadata": { "custodianId": "custodianId", "environmentId": "environmentId", "signatureApprovalMemo": "optional memo" }, "type": "PRE_SIGN_V1" } ``` ```json theme={null} { "data": { "chainId": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", // Solana Devnet "clientId": "clientId", "signingRequest": { "method": "sol_signAndSendTransaction", "params": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAED4QZTgurPkrah5b0uP9SoRjT2+hCnv/1KSr7nRZWS8aJaUV/q8rCkYm5Cw38QFa6J0WVbD1/rY7eCTuE2Bz8jVgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgje1UaxreFr4adOu8Z+RYX073+qUrItkboInHuIhJsQBAgIAAQwCAAAAQEIPAAAAAAA=" // base64 serialized transaction } }, "metadata": { "custodianId": "custodianId", "environmentId": "environmentId", "signatureApprovalMemo": "optional memo" }, "type": "PRE_SIGN_V1" } ``` ```json theme={null} { "data": { "chainId": "tron:nile", // Tron Nile Testnet "clientId": "clientId", "signingRequest": { "method": "tron_sendTransaction", "params": "CgJIziIID1+8YyspzttA0JCXytIyWmYIARJiCi10eXBlLmdvb2dsZWFwaXMuY29tL3Byb3RvY29sLlRyYW5zZmVyQ29udHJhY3QSMQoVQWbePJYEuac1QStQiExU4/oKJzizEhVBAtvp5WmlMcd8vqwl2REpbKTUQ6EY6Adw8LuTytIy" } }, "metadata": { "custodianId": "custodianId", "environmentId": "environmentId", "signatureApprovalMemo": "optional memo" }, "type": "PRE_SIGN_V1" } ``` ```json theme={null} { "data": { "chainId": "stellar:testnet", // Stellar Testnet "clientId": "clientId", "signingRequest": { "method": "stellar_sendTransaction", "params": "AAAAAgAAAADhBlOC6s+StqHlvS4/1KhGNPb6EKe//UpKvudFlZLxogAAAGQAEwrRAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAADNomLKKfoL22U8hEmltigZsRZo6o6YhQHvKPjsDTu5yAAAAAAAAAAAAAAnEAAAAAAAAAAA" } }, "metadata": { "custodianId": "custodianId", "environmentId": "environmentId", "signatureApprovalMemo": "optional memo" }, "type": "PRE_SIGN_V1" } ``` ### `EVM Wallet Transactions` Immediately after configuring the alert webhook with `EVM Wallet Transactions` selected as an event, Portal starts to listen for any inbound/outbound EIP-155 transactions, approvals, and revocations for your clients that have an EIP-155 address. From then on when you create a new client with an EIP-155 address, Portal will notify you of their on-chain transactions. When an EVM transaction occurs, Portal makes a `POST` request to your configured alert webhook URL with a request body that contains the alert webhook event's details. You will receive 2 alerts per transaction — The first alert is `unconfirmed` and, once the transaction receives sufficient confirmations, the second alert is `confirmed`. If both the sender and the receiver are addresses you've registered with Portal, you'll receive **two events per confirmation state** — one `OUTBOUND` and one `INBOUND` — for the same on-chain transfer. See [Dual events for internal transfers](#dual-events-for-internal-transfers) for the full explanation and example payloads. `EVM Wallet Transactions` currently sends alerts with the following use cases: 1. Receive native tokens (ie. `ETH`) from another address 2. Send native tokens (ie. `ETH`) to another address 3. Receive non-native tokens (ie. `USDC`) from another address 4. Send non-native tokens (ie. `USDC`) to another address 5. Approve non-native tokens (ie. approve `USDC` for a spender) 6. Revoke non-native token approvals (ie. set a spender's allowance to 0) When a block is unconfirmed, chain reorganizations may occur. If a reorganization happens, the original block's data is replaced with the updated block. This means you might receive an `unconfirmed` alert without a subsequent `confirmed` alert for the same transaction if this happens. **Retries:** When your webhook does not respond to a request, or if you do not respond with a `2XX` status code, Portal will retry the request up to 9 times with exponential backoff (see the [retry schedule](#retry-schedule)). If no successful response occurs after all retries, the webhook event will be marked `FAILED` and you can re-drive it with [`POST /custodians/me/alerts/webhooks/{alertWebhookId}/delivery-events/{deliveryEventId}/retries`](/api-reference/alert-webhooks/retry-an-alert-webhook-delivery-event). See [Delivery event lifecycle](#delivery-event-lifecycle) for details on how manual retries interact with the automatic retry loop. See the table below for all EVM chains we support for `EVM Wallet Transactions` alerts. | Name | Chain ID | Blocks until confirmed | | ---------------- | ----------------- | ---------------------- | | Ethereum | `eip155:1` | `12` | | Ethereum Sepolia | `eip155:11155111` | `18` | | Polygon | `eip155:137` | `100` | | Polygon Amoy | `eip155:80002` | `100` | | Arbitrum | `eip155:42161` | `18` | | Arbitrum Sepolia | `eip155:421614` | `600` | | Optimism | `eip155:10` | `500` | | Optimism Sepolia | `eip155:11155420` | `600` | | Base | `eip155:8453` | `100` | | Base Sepolia | `eip155:84532` | `100` | **Headers:** | Name | Type | Description | | ------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Content-Type` | String | `application/json` | | `X-WEBHOOK-SECRET` | String | You can find the alert webhook secret in the [Portal Admin Dashboard](https://app.portalhq.io) on the `"Webhooks"` page under the `"Alert Webhooks"` section. | **Request body (examples)** **Unconfirmed alert:** ```json theme={null} { "data": [ { "to": "0xab44dca8a4c606298d6be3e1b53115ed4785fe1d", // Your Portal wallet "from": "0x76acbd1eb20236f303d6f5fc3d50fe5f88caec3f", "amount": "0.01", "chainId": "eip155:11155111", "assetType": "NATIVE_TOKEN", "chainName": "sepolia", "direction": "INBOUND", "tokenSymbol": "ETH", "metadata": { "fee": { "amount": "0.001933021424187", "decimals": 18, "rawAmount": "1933021424187000", "tokenSymbol": "ETH" }, "sentAt": "2025-02-05T14:44:00.000Z", "confirmed": false, "actualGasCost": null, "formattedActualGasCost": null, "rawAmount": "10000000000000000", "tokenName": "ETH", "nftTokenId": null, "blockNumber": "7645517", "triggeredBy": "0xab44dca8a4c606298d6be3e1b53115ed4785fe1d", "tokenAddress": null, "tokenDecimals": 18, "approvalSpender": null, "transactionHash": "0xd3e43c0c352d6d60e16115416ae9d1406394e149096d449a7c88068f923ada8a", "userOperationHash": null } } ], "metadata": { "custodianId": "custodianId", "environmentId": "environmentId", "rawWebhookPayload": [], }, "type": "EIP_155_TX_V1" } ``` **Confirmed alert:** ```json theme={null} { "data": [ { "to": "0xab44dca8a4c606298d6be3e1b53115ed4785fe1d", // Your Portal wallet "from": "0x76acbd1eb20236f303d6f5fc3d50fe5f88caec3f", "amount": "0.01", "chainId": "eip155:11155111", "assetType": "NATIVE_TOKEN", "chainName": "sepolia", "direction": "INBOUND", "tokenSymbol": "ETH", "metadata": { "fee": { "amount": "0.001933021424187", "decimals": 18, "rawAmount": "1933021424187000", "tokenSymbol": "ETH" }, "sentAt": "2025-02-05T14:44:00.000Z", "confirmed": true, "actualGasCost": null, "formattedActualGasCost": null, "rawAmount": "10000000000000000", "tokenName": "ETH", "nftTokenId": null, "blockNumber": "7645517", "triggeredBy": "0xab44dca8a4c606298d6be3e1b53115ed4785fe1d", "tokenAddress": null, "tokenDecimals": 18, "approvalSpender": null, "transactionHash": "0xd3e43c0c352d6d60e16115416ae9d1406394e149096d449a7c88068f923ada8a", "userOperationHash": null } } ], "metadata": { "custodianId": "custodianId", "environmentId": "environmentId", "rawWebhookPayload": [] }, "type": "EIP_155_TX_V1" } ``` **Unconfirmed alert:** ```json theme={null} { "data": [ { "to": "0xdfd8302f44727a6348f702ff7b594f127de3a902", "from": "0xab44dca8a4c606298d6be3e1b53115ed4785fe1d", // Your Portal wallet "amount": "0.001", "chainId": "eip155:11155111", "assetType": "NATIVE_TOKEN", "chainName": "sepolia", "direction": "OUTBOUND", "tokenSymbol": "ETH", "metadata": { "fee": { "amount": "0.001955140746657", "decimals": 18, "rawAmount": "1955140746657000", "tokenSymbol": "ETH" }, "sentAt": "2025-02-05T14:52:24.000Z", "confirmed": false, "actualGasCost": null, "formattedActualGasCost": null, "rawAmount": "1000000000000000", "tokenName": "ETH", "nftTokenId": null, "blockNumber": "7645557", "triggeredBy": "0xab44dca8a4c606298d6be3e1b53115ed4785fe1d", "tokenAddress": null, "tokenDecimals": 18, "approvalSpender": null, "transactionHash": "0x297602294abe88d847a0e74af600b149cf3ed31f4cd775b37628cead9b2fabb6", "userOperationHash": null } } ], "metadata": { "custodianId": "custodianId", "environmentId": "environmentId" }, "type": "EIP_155_TX_V1" } ``` **Confirmed alert:** ```json theme={null} { "data": [ { "to": "0xdfd8302f44727a6348f702ff7b594f127de3a902", "from": "0xab44dca8a4c606298d6be3e1b53115ed4785fe1d", // Your Portal wallet "amount": "0.001", "chainId": "eip155:11155111", "assetType": "NATIVE_TOKEN", "chainName": "sepolia", "direction": "OUTBOUND", "tokenSymbol": "ETH", "metadata": { "fee": { "amount": "0.001955140746657", "decimals": 18, "rawAmount": "1955140746657000", "tokenSymbol": "ETH" }, "sentAt": "2025-02-05T14:52:24.000Z", "confirmed": true, "actualGasCost": null, "formattedActualGasCost": null, "rawAmount": "1000000000000000", "tokenName": "ETH", "nftTokenId": null, "blockNumber": "7645557", "triggeredBy": "0xab44dca8a4c606298d6be3e1b53115ed4785fe1d", "tokenAddress": null, "tokenDecimals": 18, "approvalSpender": null, "transactionHash": "0x297602294abe88d847a0e74af600b149cf3ed31f4cd775b37628cead9b2fabb6", "userOperationHash": null } } ], "metadata": { "custodianId": "custodianId", "environmentId": "environmentId" }, "type": "EIP_155_TX_V1" } ``` **Unconfirmed alert:** ```json theme={null} { "data": [ { "to": "0xab44dca8a4c606298d6be3e1b53115ed4785fe1d", // Your Portal wallet "from": "0xdfd8302f44727a6348f702ff7b594f127de3a902", "amount": "2", "chainId": "eip155:11155111", "assetType": "NON_NATIVE_TOKEN", "chainName": "sepolia", "direction": "INBOUND", "tokenSymbol": "USDT", "metadata": { "fee": { "amount": "0.003163263549129086", "decimals": 18, "rawAmount": "3163263549129086", "tokenSymbol": "ETH" }, "sentAt": "2025-02-05T16:50:12.000Z", "confirmed": false, "actualGasCost": null, "formattedActualGasCost": null, "rawAmount": "2000000", "tokenName": "USDT", "nftTokenId": null, "blockNumber": "7646117", "triggeredBy": "0xab44dca8a4c606298d6be3e1b53115ed4785fe1d", "tokenAddress": "0x419fe9f14ff3aa22e46ff1d03a73edf3b70a62ed", "tokenDecimals": "6", "approvalSpender": null, "transactionHash": "0x165f68557bb15ca9396b58f9f71f5113b83affce4fe445b37728af6c3fdb3e48", "userOperationHash": null } } ], "metadata": { "custodianId": "custodianId", "environmentId": "environmentId" }, "type": "EIP_155_TX_V1" } ``` **Confirmed alert:** ```json theme={null} { "data": [ { "to": "0xab44dca8a4c606298d6be3e1b53115ed4785fe1d", // Your Portal wallet "from": "0xdfd8302f44727a6348f702ff7b594f127de3a902", "amount": "2", "chainId": "eip155:11155111", "assetType": "NON_NATIVE_TOKEN", "chainName": "sepolia", "direction": "INBOUND", "tokenSymbol": "USDT", "metadata": { "fee": { "amount": "0.003163263549129086", "decimals": 18, "rawAmount": "3163263549129086", "tokenSymbol": "ETH" }, "sentAt": "2025-02-05T16:50:12.000Z", "confirmed": true, "actualGasCost": null, "formattedActualGasCost": null, "rawAmount": "2000000", "tokenName": "USDT", "nftTokenId": null, "blockNumber": "7646117", "triggeredBy": "0xab44dca8a4c606298d6be3e1b53115ed4785fe1d", "tokenAddress": "0x419fe9f14ff3aa22e46ff1d03a73edf3b70a62ed", "tokenDecimals": "6", "approvalSpender": null, "transactionHash": "0x165f68557bb15ca9396b58f9f71f5113b83affce4fe445b37728af6c3fdb3e48", "userOperationHash": null } } ], "metadata": { "custodianId": "custodianId", "environmentId": "environmentId" }, "type": "EIP_155_TX_V1" } ``` **Unconfirmed alert:** ```json theme={null} { "data": [ { "to": "0xdfd8302f44727a6348f702ff7b594f127de3a902", "from": "0xab44dca8a4c606298d6be3e1b53115ed4785fe1d", // Your Portal wallet "amount": "0.1", "chainId": "eip155:11155111", "assetType": "NON_NATIVE_TOKEN", "chainName": "sepolia", "direction": "OUTBOUND", "tokenSymbol": "USDT", "metadata": { "fee": { "amount": "0.002395433635667721", "decimals": 18, "rawAmount": "2395433635667721", "tokenSymbol": "ETH" }, "sentAt": "2025-02-05T16:58:36.000Z", "confirmed": false, "actualGasCost": null, "formattedActualGasCost": null, "rawAmount": "100000", "tokenName": "USDT", "nftTokenId": null, "blockNumber": "7646155", "triggeredBy": "0xab44dca8a4c606298d6be3e1b53115ed4785fe1d", "tokenAddress": "0x419fe9f14ff3aa22e46ff1d03a73edf3b70a62ed", "tokenDecimals": "6", "approvalSpender": null, "transactionHash": "0xa1da1738225b012c328724454dd46db4279133cfbf4a3054c094f3986de9ffe0", "userOperationHash": null } } ], "metadata": { "custodianId": "custodianId", "environmentId": "environmentId" }, "type": "EIP_155_TX_V1" } ``` **Confirmed alert:** ```json theme={null} { "data": [ { "to": "0xdfd8302f44727a6348f702ff7b594f127de3a902", "from": "0xab44dca8a4c606298d6be3e1b53115ed4785fe1d", // Your Portal wallet "amount": "0.1", "chainId": "eip155:11155111", "assetType": "NON_NATIVE_TOKEN", "chainName": "sepolia", "direction": "OUTBOUND", "tokenSymbol": "USDT", "metadata": { "fee": { "amount": "0.002395433635667721", "decimals": 18, "rawAmount": "2395433635667721", "tokenSymbol": "ETH" }, "sentAt": "2025-02-05T16:58:36.000Z", "confirmed": true, "actualGasCost": null, "formattedActualGasCost": null, "rawAmount": "100000", "tokenName": "USDT", "nftTokenId": null, "blockNumber": "7646155", "triggeredBy": "0xab44dca8a4c606298d6be3e1b53115ed4785fe1d", "tokenAddress": "0x419fe9f14ff3aa22e46ff1d03a73edf3b70a62ed", "tokenDecimals": "6", "approvalSpender": null, "transactionHash": "0xa1da1738225b012c328724454dd46db4279133cfbf4a3054c094f3986de9ffe0", "userOperationHash": null } } ], "metadata": { "custodianId": "custodianId", "environmentId": "environmentId" }, "type": "EIP_155_TX_V1" } ``` **Unconfirmed alert:** ```json theme={null} { "data": [ { "to": "0xee5ca6275c9cfaec5ad42b40ebdbe6073fbcc4a5", "from": "0x8cd6145d0555b867a708ad92f311bc6d67d41fd2", // Your Portal wallet "amount": "0.12345", "chainId": "eip155:11155111", "metadata": { "fee": { "amount": "0.000472352150647304", "decimals": 18, "rawAmount": "472352150647304", "tokenSymbol": "ETH" }, "sentAt": "2026-02-10T18:30:12.000Z", "confirmed": false, "actualGasCost": "0x39fb8298c6e800", "formattedActualGasCost": "16320612000000000", "rawAmount": "123450", "tokenName": "USDC", "nftTokenId": null, "blockNumber": "10233184", "triggeredBy": "0x8cd6145d0555b867a708ad92f311bc6d67d41fd2", "tokenAddress": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", "tokenDecimals": "6", "approvalSpender": "0xee5ca6275c9cfaec5ad42b40ebdbe6073fbcc4a5", "transactionHash": "0x39537e4f9a06d0356ea2c201190ff9fd2ffa4c1220c5b3eca7e15b1c03f64273", "userOperationHash": "0x13b97495af27950461811eb68667d3ca631f9a510d5fe6b422d964962fbe995a" }, "assetType": "ERC20_APPROVAL", "chainName": "sepolia", "direction": "OUTBOUND", "tokenSymbol": "USDC" } ], "metadata": { "txs": [ { "r": "39239007254912289268771055931432113831270460789053050427072713407646027577443", "s": "26623931638950271666893416961845058711739468843419989769967475848834369193733", "v": "0", "gas": "446711", "hash": "0x39537e4f9a06d0356ea2c201190ff9fd2ffa4c1220c5b3eca7e15b1c03f64273", "type": "2", "input": "0x1fad948c00000000000000000000000000000000000000000000000000000000000000400000000000000000000000004337016838785634c63fce393bfc6222564436c4000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000008cd6145d0555b867a708ad92f311bc6d67d41fd200000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000160000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000200de000000000000000000000000000000000000000000000000000000000001e41f000000000000000000000000000000000000000000000000000000000000d33c0000000000000000000000000000000000000000000000000000000054c87c2e0000000000000000000000000000000000000000000000000000000000195d3600000000000000000000000000000000000000000000000000000000000002c0000000000000000000000000000000000000000000000000000000000000036000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000104519454470000000000000000000000001c7d4b196cb0c7b01d743fbc6116a902379c72380000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044095ea7b3000000000000000000000000ee5ca6275c9cfaec5ad42b40ebdbe6073fbcc4a5000000000000000000000000000000000000000000000000000000000001e23a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000626666666666667849c56f2850848ce1c4da65c68b010000698b7b7f000000000000053dd9ed70c3f6ad900618ff48c6c00d5602d43378f8ca53f57e962c7a74e3cd38ecd4efa48743a0178be82451503ad6eadac99a6bab178321046537e6ef1a7a1c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004500000000295dde855329661f3fbb2f2e84a269102acc5211aa255c3007e60368f2bc13b479bf98ea586f92485e23a404d125421d2ae2fda087337471c2e889535c6262bd1b000000000000000000000000000000000000000000000000000000", "nonce": "58551", "value": "0", "gasPrice": "1057399864", "toAddress": "0x5ff137d4b0fdcd49dca30c7cf57e578a026d2789", "fromAddress": "0x4337016838785634c63fce393bfc6222564436c4", "receiptRoot": null, "triggered_by": ["0x8cd6145d0555b867a708ad92f311bc6d67d41fd2"], "receiptStatus": "1", "receiptGasUsed": "153229", "transactionFee": "0.000162024323760856", "transactionIndex": "97", "receiptContractAddress": null, "receiptCumulativeGasUsed": "19941570" } ], "logs": [ { "data": "0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "topic0": "0x7a270f29ae17e8e2304ff1245deb50c3b6206bca82928d904f3e284d35c5ffd2", "topic1": "0x13b97495af27950461811eb68667d3ca631f9a510d5fe6b422d964962fbe995a", "topic2": "0x0000000000000000000000008cd6145d0555b867a708ad92f311bc6d67d41fd2", "topic3": null, "address": "0x6666666666667849c56f2850848ce1c4da65c68b", "logIndex": "193", "triggered_by": ["0x8cd6145d0555b867a708ad92f311bc6d67d41fd2"], "transactionHash": "0x39537e4f9a06d0356ea2c201190ff9fd2ffa4c1220c5b3eca7e15b1c03f64273" }, { "data": "0x000000000000000000000000000000000000000000000000000000000001e23a", "topic0": "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", "topic1": "0x0000000000000000000000008cd6145d0555b867a708ad92f311bc6d67d41fd2", "topic2": "0x000000000000000000000000ee5ca6275c9cfaec5ad42b40ebdbe6073fbcc4a5", "topic3": null, "address": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", "logIndex": "195", "triggered_by": [ "0x8cd6145d0555b867a708ad92f311bc6d67d41fd2", "0xee5ca6275c9cfaec5ad42b40ebdbe6073fbcc4a5" ], "transactionHash": "0x39537e4f9a06d0356ea2c201190ff9fd2ffa4c1220c5b3eca7e15b1c03f64273" }, { "data": "0x00000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000098b979dbd6590000000000000000000000000000000000000000000000000000000000026c4b", "topic0": "0x49628fd1471006c1482da88028e9ce4dbb080b815c9b0344d39e5a8e6ec1419f", "topic1": "0x13b97495af27950461811eb68667d3ca631f9a510d5fe6b422d964962fbe995a", "topic2": "0x0000000000000000000000008cd6145d0555b867a708ad92f311bc6d67d41fd2", "topic3": "0x0000000000000000000000006666666666667849c56f2850848ce1c4da65c68b", "address": "0x5ff137d4b0fdcd49dca30c7cf57e578a026d2789", "logIndex": "196", "triggered_by": ["0x8cd6145d0555b867a708ad92f311bc6d67d41fd2"], "transactionHash": "0x39537e4f9a06d0356ea2c201190ff9fd2ffa4c1220c5b3eca7e15b1c03f64273" } ], "block": { "hash": "0x90e7bfd7100ae7858028e870efd6d195814ac2cb0ccadf88ebc3e901e053c77e", "number": "10233184", "timestamp": "1770748212" }, "chainId": "eip155:11155111", "retries": 0, "confirmed": false, "custodianId": "cl7hygkd200063g6l5nn0", "environmentId": "8c95033b-f3f0-4a15-a4ae-8938e3087990" }, "type": "EIP_155_TX_V1" } ``` **Confirmed alert:** ```json theme={null} { "data": [ { "to": "0xee5ca6275c9cfaec5ad42b40ebdbe6073fbcc4a5", "from": "0x8cd6145d0555b867a708ad92f311bc6d67d41fd2", // Your Portal wallet "amount": "0.12345", "chainId": "eip155:11155111", "metadata": { "fee": { "amount": "0.000472352150647304", "decimals": 18, "rawAmount": "472352150647304", "tokenSymbol": "ETH" }, "sentAt": "2026-02-10T18:30:12.000Z", "confirmed": true, "actualGasCost": "0x39fb8298c6e800", "formattedActualGasCost": "16320612000000000", "rawAmount": "123450", "tokenName": "USDC", "nftTokenId": null, "blockNumber": "10233184", "triggeredBy": "0x8cd6145d0555b867a708ad92f311bc6d67d41fd2", "tokenAddress": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", "tokenDecimals": "6", "approvalSpender": "0xee5ca6275c9cfaec5ad42b40ebdbe6073fbcc4a5", "transactionHash": "0x39537e4f9a06d0356ea2c201190ff9fd2ffa4c1220c5b3eca7e15b1c03f64273", "userOperationHash": "0x13b97495af27950461811eb68667d3ca631f9a510d5fe6b422d964962fbe995a" }, "assetType": "ERC20_APPROVAL", "chainName": "sepolia", "direction": "OUTBOUND", "tokenSymbol": "USDC" } ], "metadata": { "txs": [ { "r": "39239007254912289268771055931432113831270460789053050427072713407646027577443", "s": "26623931638950271666893416961845058711739468843419989769967475848834369193733", "v": "0", "gas": "446711", "hash": "0x39537e4f9a06d0356ea2c201190ff9fd2ffa4c1220c5b3eca7e15b1c03f64273", "type": "2", "input": "0x1fad948c00000000000000000000000000000000000000000000000000000000000000400000000000000000000000004337016838785634c63fce393bfc6222564436c4000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000008cd6145d0555b867a708ad92f311bc6d67d41fd200000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000160000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000200de000000000000000000000000000000000000000000000000000000000001e41f000000000000000000000000000000000000000000000000000000000000d33c0000000000000000000000000000000000000000000000000000000054c87c2e0000000000000000000000000000000000000000000000000000000000195d3600000000000000000000000000000000000000000000000000000000000002c0000000000000000000000000000000000000000000000000000000000000036000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000104519454470000000000000000000000001c7d4b196cb0c7b01d743fbc6116a902379c72380000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044095ea7b3000000000000000000000000ee5ca6275c9cfaec5ad42b40ebdbe6073fbcc4a5000000000000000000000000000000000000000000000000000000000001e23a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000626666666666667849c56f2850848ce1c4da65c68b010000698b7b7f000000000000053dd9ed70c3f6ad900618ff48c6c00d5602d43378f8ca53f57e962c7a74e3cd38ecd4efa48743a0178be82451503ad6eadac99a6bab178321046537e6ef1a7a1c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004500000000295dde855329661f3fbb2f2e84a269102acc5211aa255c3007e60368f2bc13b479bf98ea586f92485e23a404d125421d2ae2fda087337471c2e889535c6262bd1b000000000000000000000000000000000000000000000000000000", "nonce": "58551", "value": "0", "gasPrice": "1057399864", "toAddress": "0x5ff137d4b0fdcd49dca30c7cf57e578a026d2789", "fromAddress": "0x4337016838785634c63fce393bfc6222564436c4", "receiptRoot": null, "triggered_by": ["0x8cd6145d0555b867a708ad92f311bc6d67d41fd2"], "receiptStatus": "1", "receiptGasUsed": "153229", "transactionFee": "0.000162024323760856", "transactionIndex": "97", "receiptContractAddress": null, "receiptCumulativeGasUsed": "19941570" } ], "logs": [ { "data": "0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "topic0": "0x7a270f29ae17e8e2304ff1245deb50c3b6206bca82928d904f3e284d35c5ffd2", "topic1": "0x13b97495af27950461811eb68667d3ca631f9a510d5fe6b422d964962fbe995a", "topic2": "0x0000000000000000000000008cd6145d0555b867a708ad92f311bc6d67d41fd2", "topic3": null, "address": "0x6666666666667849c56f2850848ce1c4da65c68b", "logIndex": "193", "triggered_by": ["0x8cd6145d0555b867a708ad92f311bc6d67d41fd2"], "transactionHash": "0x39537e4f9a06d0356ea2c201190ff9fd2ffa4c1220c5b3eca7e15b1c03f64273" }, { "data": "0x000000000000000000000000000000000000000000000000000000000001e23a", "topic0": "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", "topic1": "0x0000000000000000000000008cd6145d0555b867a708ad92f311bc6d67d41fd2", "topic2": "0x000000000000000000000000ee5ca6275c9cfaec5ad42b40ebdbe6073fbcc4a5", "topic3": null, "address": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", "logIndex": "195", "triggered_by": [ "0x8cd6145d0555b867a708ad92f311bc6d67d41fd2", "0xee5ca6275c9cfaec5ad42b40ebdbe6073fbcc4a5" ], "transactionHash": "0x39537e4f9a06d0356ea2c201190ff9fd2ffa4c1220c5b3eca7e15b1c03f64273" }, { "data": "0x00000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000098b979dbd6590000000000000000000000000000000000000000000000000000000000026c4b", "topic0": "0x49628fd1471006c1482da88028e9ce4dbb080b815c9b0344d39e5a8e6ec1419f", "topic1": "0x13b97495af27950461811eb68667d3ca631f9a510d5fe6b422d964962fbe995a", "topic2": "0x0000000000000000000000008cd6145d0555b867a708ad92f311bc6d67d41fd2", "topic3": "0x0000000000000000000000006666666666667849c56f2850848ce1c4da65c68b", "address": "0x5ff137d4b0fdcd49dca30c7cf57e578a026d2789", "logIndex": "196", "triggered_by": ["0x8cd6145d0555b867a708ad92f311bc6d67d41fd2"], "transactionHash": "0x39537e4f9a06d0356ea2c201190ff9fd2ffa4c1220c5b3eca7e15b1c03f64273" } ], "block": { "hash": "0x90e7bfd7100ae7858028e870efd6d195814ac2cb0ccadf88ebc3e901e053c77e", "number": "10233184", "timestamp": "1770748212" }, "chainId": "eip155:11155111", "retries": 0, "confirmed": true, "custodianId": "cl7hygkd200063g6l5nn0", "environmentId": "8c95033b-f3f0-4a15-a4ae-8938e3087990" }, "type": "EIP_155_TX_V1" } ``` **Unconfirmed alert:** ```json theme={null} { "data": [ { "to": "0xee5ca6275c9cfaec5ad42b40ebdbe6073fbcc4a5", "from": "0x8cd6145d0555b867a708ad92f311bc6d67d41fd2", // Your Portal wallet "amount": "0", "chainId": "eip155:11155111", "metadata": { "fee": { "amount": "0.000445696678391486", "decimals": 18, "rawAmount": "445696678391486", "tokenSymbol": "ETH" }, "sentAt": "2026-02-10T18:37:12.000Z", "confirmed": false, "actualGasCost": "0x39fb8298c6e800", "formattedActualGasCost": "16320612000000000", "rawAmount": "0", "tokenName": "USDC", "nftTokenId": null, "blockNumber": "10233219", "triggeredBy": "0x8cd6145d0555b867a708ad92f311bc6d67d41fd2", "tokenAddress": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", "tokenDecimals": "6", "approvalSpender": "0xee5ca6275c9cfaec5ad42b40ebdbe6073fbcc4a5", "transactionHash": "0x433aade80380542c5f7294ba4eb43d08bb09352a605a1e41eeab88a823d4bb95", "userOperationHash": "0xf55e82b32f6d94a13607882f976a45834f2555faa871b1ea93c74ddc50857ede" }, "assetType": "ERC20_APPROVAL", "chainName": "sepolia", "direction": "OUTBOUND", "tokenSymbol": "USDC" } ], "metadata": { "txs": [ { "r": "69800324002112404822417539847516648270674922755017207604406921620605970296563", "s": "4822640581286792349111363455420646915276302091523407157768067240978332247031", "v": "1", "gas": "420689", "hash": "0x433aade80380542c5f7294ba4eb43d08bb09352a605a1e41eeab88a823d4bb95", "type": "2", "input": "0x1fad948c00000000000000000000000000000000000000000000000000000000000000400000000000000000000000004337013ef5cd421cef649fc58c1b0defd37bbeb1000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000008cd6145d0555b867a708ad92f311bc6d67d41fd2000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000001600000000000000000000000000000000000000000000000000000000000000180000000000000000000000000000000000000000000000000000000000001a396000000000000000000000000000000000000000000000000000000000001e41f000000000000000000000000000000000000000000000000000000000000d3140000000000000000000000000000000000000000000000000000000050bfe2440000000000000000000000000000000000000000000000000000000000195d3600000000000000000000000000000000000000000000000000000000000002c0000000000000000000000000000000000000000000000000000000000000036000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000104519454470000000000000000000000001c7d4b196cb0c7b01d743fbc6116a902379c72380000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044095ea7b3000000000000000000000000ee5ca6275c9cfaec5ad42b40ebdbe6073fbcc4a50000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000626666666666667849c56f2850848ce1c4da65c68b010000698b7d21000000000000d40df9ba9d664ab5a8ff593573c305b43d64e873527d5cf6ce67dc48f99c16fa7f20287cc8c6c962d52a148fcafc96ec8b40606fb5a07c9da959e9f35207a5fe1c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004500000000bb5aaeb821928ef77ebdc0fe2e8c78fcec7e77cb7f7ab4892c33308605436aea164785e076475f85cbc15d70d937422a12d9a4735d9714ffde7e41fd1776e2321c000000000000000000000000000000000000000000000000000000", "nonce": "58458", "value": "0", "gasPrice": "1059444574", "toAddress": "0x5ff137d4b0fdcd49dca30c7cf57e578a026d2789", "fromAddress": "0x4337013ef5cd421cef649fc58c1b0defd37bbeb1", "receiptRoot": null, "triggered_by": ["0x8cd6145d0555b867a708ad92f311bc6d67d41fd2"], "receiptStatus": "1", "receiptGasUsed": "133305", "transactionFee": "0.000141229258937070", "transactionIndex": "96", "receiptContractAddress": null, "receiptCumulativeGasUsed": "11334942" } ], "logs": [ { "data": "0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "topic0": "0x7a270f29ae17e8e2304ff1245deb50c3b6206bca82928d904f3e284d35c5ffd2", "topic1": "0xf55e82b32f6d94a13607882f976a45834f2555faa871b1ea93c74ddc50857ede", "topic2": "0x0000000000000000000000008cd6145d0555b867a708ad92f311bc6d67d41fd2", "topic3": null, "address": "0x6666666666667849c56f2850848ce1c4da65c68b", "logIndex": "161", "triggered_by": ["0x8cd6145d0555b867a708ad92f311bc6d67d41fd2"], "transactionHash": "0x433aade80380542c5f7294ba4eb43d08bb09352a605a1e41eeab88a823d4bb95" }, { "data": "0x0000000000000000000000000000000000000000000000000000000000000000", "topic0": "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", "topic1": "0x0000000000000000000000008cd6145d0555b867a708ad92f311bc6d67d41fd2", "topic2": "0x000000000000000000000000ee5ca6275c9cfaec5ad42b40ebdbe6073fbcc4a5", "topic3": null, "address": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", "logIndex": "163", "triggered_by": [ "0x8cd6145d0555b867a708ad92f311bc6d67d41fd2", "0xee5ca6275c9cfaec5ad42b40ebdbe6073fbcc4a5" ], "transactionHash": "0x433aade80380542c5f7294ba4eb43d08bb09352a605a1e41eeab88a823d4bb95" }, { "data": "0x00000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000085ce15510c570000000000000000000000000000000000000000000000000000000000021e67", "topic0": "0x49628fd1471006c1482da88028e9ce4dbb080b815c9b0344d39e5a8e6ec1419f", "topic1": "0xf55e82b32f6d94a13607882f976a45834f2555faa871b1ea93c74ddc50857ede", "topic2": "0x0000000000000000000000008cd6145d0555b867a708ad92f311bc6d67d41fd2", "topic3": "0x0000000000000000000000006666666666667849c56f2850848ce1c4da65c68b", "address": "0x5ff137d4b0fdcd49dca30c7cf57e578a026d2789", "logIndex": "164", "triggered_by": ["0x8cd6145d0555b867a708ad92f311bc6d67d41fd2"], "transactionHash": "0x433aade80380542c5f7294ba4eb43d08bb09352a605a1e41eeab88a823d4bb95" } ], "block": { "hash": "0x2778642f3ff6ae0dd81b8fa81185ab4f6bd7f7b28197eaba1593808cac0cd985", "number": "10233219", "timestamp": "1770748632" }, "chainId": "eip155:11155111", "retries": 0, "confirmed": false, "custodianId": "cl7hygkd200063g6l5nn0", "environmentId": "8c95033b-f3f0-4a15-a4ae-8938e3087990" }, "type": "EIP_155_TX_V1" } ``` **Confirmed alert:** ```json theme={null} { "data": [ { "to": "0xee5ca6275c9cfaec5ad42b40ebdbe6073fbcc4a5", "from": "0x8cd6145d0555b867a708ad92f311bc6d67d41fd2", // Your Portal wallet "amount": "0", "chainId": "eip155:11155111", "metadata": { "fee": { "amount": "0.000445696678391486", "decimals": 18, "rawAmount": "445696678391486", "tokenSymbol": "ETH" }, "sentAt": "2026-02-10T18:37:12.000Z", "confirmed": true, "actualGasCost": "0x39fb8298c6e800", "formattedActualGasCost": "16320612000000000", "rawAmount": "0", "tokenName": "USDC", "nftTokenId": null, "blockNumber": "10233219", "triggeredBy": "0x8cd6145d0555b867a708ad92f311bc6d67d41fd2", "tokenAddress": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", "tokenDecimals": "6", "approvalSpender": "0xee5ca6275c9cfaec5ad42b40ebdbe6073fbcc4a5", "transactionHash": "0x433aade80380542c5f7294ba4eb43d08bb09352a605a1e41eeab88a823d4bb95", "userOperationHash": "0xf55e82b32f6d94a13607882f976a45834f2555faa871b1ea93c74ddc50857ede" }, "assetType": "ERC20_APPROVAL", "chainName": "sepolia", "direction": "OUTBOUND", "tokenSymbol": "USDC" } ], "metadata": { "txs": [ { "r": "69800324002112404822417539847516648270674922755017207604406921620605970296563", "s": "4822640581286792349111363455420646915276302091523407157768067240978332247031", "v": "1", "gas": "420689", "hash": "0x433aade80380542c5f7294ba4eb43d08bb09352a605a1e41eeab88a823d4bb95", "type": "2", "input": "0x1fad948c00000000000000000000000000000000000000000000000000000000000000400000000000000000000000004337013ef5cd421cef649fc58c1b0defd37bbeb1000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000008cd6145d0555b867a708ad92f311bc6d67d41fd2000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000001600000000000000000000000000000000000000000000000000000000000000180000000000000000000000000000000000000000000000000000000000001a396000000000000000000000000000000000000000000000000000000000001e41f000000000000000000000000000000000000000000000000000000000000d3140000000000000000000000000000000000000000000000000000000050bfe2440000000000000000000000000000000000000000000000000000000000195d3600000000000000000000000000000000000000000000000000000000000002c0000000000000000000000000000000000000000000000000000000000000036000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000104519454470000000000000000000000001c7d4b196cb0c7b01d743fbc6116a902379c72380000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044095ea7b3000000000000000000000000ee5ca6275c9cfaec5ad42b40ebdbe6073fbcc4a50000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000626666666666667849c56f2850848ce1c4da65c68b010000698b7d21000000000000d40df9ba9d664ab5a8ff593573c305b43d64e873527d5cf6ce67dc48f99c16fa7f20287cc8c6c962d52a148fcafc96ec8b40606fb5a07c9da959e9f35207a5fe1c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004500000000bb5aaeb821928ef77ebdc0fe2e8c78fcec7e77cb7f7ab4892c33308605436aea164785e076475f85cbc15d70d937422a12d9a4735d9714ffde7e41fd1776e2321c000000000000000000000000000000000000000000000000000000", "nonce": "58458", "value": "0", "gasPrice": "1059444574", "toAddress": "0x5ff137d4b0fdcd49dca30c7cf57e578a026d2789", "fromAddress": "0x4337013ef5cd421cef649fc58c1b0defd37bbeb1", "receiptRoot": null, "triggered_by": ["0x8cd6145d0555b867a708ad92f311bc6d67d41fd2"], "receiptStatus": "1", "receiptGasUsed": "133305", "transactionFee": "0.000141229258937070", "transactionIndex": "96", "receiptContractAddress": null, "receiptCumulativeGasUsed": "11334942" } ], "logs": [ { "data": "0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "topic0": "0x7a270f29ae17e8e2304ff1245deb50c3b6206bca82928d904f3e284d35c5ffd2", "topic1": "0xf55e82b32f6d94a13607882f976a45834f2555faa871b1ea93c74ddc50857ede", "topic2": "0x0000000000000000000000008cd6145d0555b867a708ad92f311bc6d67d41fd2", "topic3": null, "address": "0x6666666666667849c56f2850848ce1c4da65c68b", "logIndex": "161", "triggered_by": ["0x8cd6145d0555b867a708ad92f311bc6d67d41fd2"], "transactionHash": "0x433aade80380542c5f7294ba4eb43d08bb09352a605a1e41eeab88a823d4bb95" }, { "data": "0x0000000000000000000000000000000000000000000000000000000000000000", "topic0": "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", "topic1": "0x0000000000000000000000008cd6145d0555b867a708ad92f311bc6d67d41fd2", "topic2": "0x000000000000000000000000ee5ca6275c9cfaec5ad42b40ebdbe6073fbcc4a5", "topic3": null, "address": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", "logIndex": "163", "triggered_by": [ "0x8cd6145d0555b867a708ad92f311bc6d67d41fd2", "0xee5ca6275c9cfaec5ad42b40ebdbe6073fbcc4a5" ], "transactionHash": "0x433aade80380542c5f7294ba4eb43d08bb09352a605a1e41eeab88a823d4bb95" }, { "data": "0x00000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000085ce15510c570000000000000000000000000000000000000000000000000000000000021e67", "topic0": "0x49628fd1471006c1482da88028e9ce4dbb080b815c9b0344d39e5a8e6ec1419f", "topic1": "0xf55e82b32f6d94a13607882f976a45834f2555faa871b1ea93c74ddc50857ede", "topic2": "0x0000000000000000000000008cd6145d0555b867a708ad92f311bc6d67d41fd2", "topic3": "0x0000000000000000000000006666666666667849c56f2850848ce1c4da65c68b", "address": "0x5ff137d4b0fdcd49dca30c7cf57e578a026d2789", "logIndex": "164", "triggered_by": ["0x8cd6145d0555b867a708ad92f311bc6d67d41fd2"], "transactionHash": "0x433aade80380542c5f7294ba4eb43d08bb09352a605a1e41eeab88a823d4bb95" } ], "block": { "hash": "0x2778642f3ff6ae0dd81b8fa81185ab4f6bd7f7b28197eaba1593808cac0cd985", "number": "10233219", "timestamp": "1770748632" }, "chainId": "eip155:11155111", "retries": 0, "confirmed": true, "custodianId": "cl7hygkd200063g6l5nn0", "environmentId": "8c95033b-f3f0-4a15-a4ae-8938e3087990" }, "type": "EIP_155_TX_V1" } ``` `actualGasCost` and `formattedActualGasCost` are only populated for clients whose custodian has Account Abstraction enabled, and only when the transaction was executed as a user operation. `actualGasCost` is a hex-encoded Wei amount (with `0x` prefix); `formattedActualGasCost` is the same value as a base-10 Wei string. When a gas policy is enabled for the chain, these values reflect the gas you sponsored for that user operation. ### `Solana Wallet Transactions` Once you set up an alert webhook with `Solana Wallet Transactions` as the event, Portal begins monitoring *transfer* transactions for your Portal clients with a Solana address. Any new client created with a Solana address will also trigger notifications for their on-chain transfers. Portal sends one event per subscribed address involved in the transaction, identified by a `triggeredBy` field on the payload. When a Solana transfer transaction occurs, Portal makes a `POST` request to your configured alert webhook URL with a request body that contains the alert webhook event's details. You will receive 1 alert per subscribed address involved when the transaction is `confirmed`. Still on the legacy Solana alert webhook events? See [`Solana Wallet Transactions (Legacy)`](#solana-wallet-transactions-legacy) and the [migration guide](/resources/migrating-from-solana-legacy-webhooks). #### Direction Every Solana alert webhook event includes a `direction` field that describes what the `triggeredBy` address did in the transaction. Use it together with `triggeredBy` to route events without re-deriving the role from the raw transaction data. If both the sender and the receiver are addresses you've registered with Portal, you'll receive **two events** for the same on-chain transfer — one with `direction: OUTBOUND` and one with `direction: INBOUND`. See [Dual events for internal transfers](#dual-events-for-internal-transfers) for details on correlating the two. | Value | Meaning | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `OUTBOUND` | `triggeredBy` is the sender of the transaction — the one who sent native or non-native tokens, who approved or revoked a delegation, or who executed a delegated transfer. | | `INBOUND` | `triggeredBy` is the receiver of the transaction — the one who received tokens, or who was granted approval to spend delegated tokens. | | `DELEGATED` | Only on `Solana Delegated Transfers` events: `triggeredBy` is the owner whose tokens are being moved by the delegate (the source of funds in the delegated transfer, distinct from the delegate that executed it). | `Solana Wallet Transactions` currently sends alerts for non-delegated transactions with the following use cases: 1. Receive native tokens (ie. `SOL`) from another address 2. Send native tokens (ie. `SOL`) to another address 3. Receive non-native tokens (ie. `USDC`) from another address 4. Send non-native tokens (ie. `USDC`) to another address For delegated transfers (a delegate spending an approved allowance), see [`Solana Delegated Transfers`](#solana-delegated-transfers). Solana alert webhook events support **up to 1,000,000 addresses**. Contact our team if you require additional capacity. After you create a new Portal client wallet, it can take **up to 4 minutes** before you start to receive Solana transaction events for them. **Retries:** When your webhook does not respond to a request, or if you do not respond with a `2XX` status code, Portal will retry the request up to 9 times with exponential backoff (see the [retry schedule](#retry-schedule)). If no successful response occurs after all retries, the webhook event will be marked `FAILED` and you can re-drive it with [`POST /custodians/me/alerts/webhooks/{alertWebhookId}/delivery-events/{deliveryEventId}/retries`](/api-reference/alert-webhooks/retry-an-alert-webhook-delivery-event). See [Delivery event lifecycle](#delivery-event-lifecycle) for details on how manual retries interact with the automatic retry loop. **Headers:** | Name | Type | Description | | ------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Content-Type` | String | `application/json` | | `X-WEBHOOK-SECRET` | String | You can find the alert webhook secret in the [Portal Admin Dashboard](https://app.portalhq.io) on the `"Webhooks"` page under the `"Alert Webhooks"` section. | **Request body (examples)** ```json theme={null} { "data": { "rawEvents": [ { "fee": 80000, "slot": 379135212, "type": "TRANSFER", "events": {}, "source": "SYSTEM_PROGRAM", "feePayer": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "signature": "5hyWP82Fn951zjo5DHWAvAC8NjwU7LAKqR3Rpn7sB1H159koer8BefYSSCRuC7X3xVy6rHvMzM7sYHLBwxTAEeUY", "timestamp": 1746565669, "triggeredBy": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "direction": "INBOUND", "accountData": [ { "account": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "nativeBalanceChange": -10080000, "tokenBalanceChanges": [] }, { "account": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "nativeBalanceChange": 10000000, "tokenBalanceChanges": [] }, { "account": "11111111111111111111111111111111", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "ComputeBudget111111111111111111111111111111", "nativeBalanceChange": 0, "tokenBalanceChanges": [] } ], "description": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H transferred 0.01 SOL to 67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx.", "instructions": [ { "data": "3b1H8Rq1T3d1", "accounts": [], "programId": "ComputeBudget111111111111111111111111111111", "innerInstructions": [] }, { "data": "LKoyXd", "accounts": [], "programId": "ComputeBudget111111111111111111111111111111", "innerInstructions": [] }, { "data": "3Bxs4NN8M2Yn4TLb", "accounts": [ "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx" ], "programId": "11111111111111111111111111111111", "innerInstructions": [] } ], "tokenTransfers": [], "nativeTransfers": [ { "amount": 10000000, "toUserAccount": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "fromUserAccount": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H" } ], "recentBlockhash": "2d7rAqbS4ZFsroMHkxA8kmJdvmoPy4mtZRqCpSNTa1DQ", "transactionError": null } ] }, "metadata": { "isDevnet": true, "eventTypes": ["TRANSFER"], "environmentId": "environmentId" }, "type": "SOLANA_TX_V2" } ``` ```json theme={null} { "data": { "rawEvents": [ { "fee": 5000, "slot": 379135804, "type": "TRANSFER", "events": {}, "source": "SYSTEM_PROGRAM", "feePayer": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "signature": "KENcRJpHNowJcpDwPvB2yR6VnDzMbQu5Nd1om5K1Y3cjdS3ujStBWYtz3EMhY1UvzykShhdZSUShwZrmYWbxfAF", "timestamp": 1746565903, "triggeredBy": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "direction": "OUTBOUND", "accountData": [ { "account": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "nativeBalanceChange": -205000, "tokenBalanceChanges": [] }, { "account": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "nativeBalanceChange": 200000, "tokenBalanceChanges": [] }, { "account": "11111111111111111111111111111111", "nativeBalanceChange": 0, "tokenBalanceChanges": [] } ], "description": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx transferred 0.0002 SOL to 75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H.", "instructions": [ { "data": "3Bxs4Ba2u7BkmhYB", "accounts": [ "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H" ], "programId": "11111111111111111111111111111111", "innerInstructions": [] } ], "tokenTransfers": [], "nativeTransfers": [ { "amount": 200000, "toUserAccount": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "fromUserAccount": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx" } ], "recentBlockhash": "2d7rAqbS4ZFsroMHkxA8kmJdvmoPy4mtZRqCpSNTa1DQ", "transactionError": null } ] }, "metadata": { "isDevnet": true, "eventTypes": ["TRANSFER"], "environmentId": "environmentId" }, "type": "SOLANA_TX_V2" } ``` ```json theme={null} { "data": { "rawEvents": [ { "fee": 80001, "slot": 413214028, "type": "TRANSFER", "events": {}, "source": "SOLANA_PROGRAM_LIBRARY", "feePayer": "9Nmpej98v2kEAXtc4AD52EbQAFrc3ikRe881LehqsEHD", "signature": "3Cek3WrggVJ6PgwMXgnEZS4pyCqbLpZeeo2HWhJYB1McZoKZvyQU497F2yRnTJ3UWBoKcpeL8rd3Bw4pu4FKxyNo", "timestamp": 1776189489, "triggeredBy": "49bNwvPHy3krxWLBU3qrK5Gm1RPFqWDXB8xaawcjyCjd", "direction": "INBOUND", "accountData": [ { "account": "9Nmpej98v2kEAXtc4AD52EbQAFrc3ikRe881LehqsEHD", "nativeBalanceChange": -80001, "tokenBalanceChanges": [] }, { "account": "C2NWfgp2qUb2papQzy28XQPCxeT84zDLSPJRZvXGeZBp", "nativeBalanceChange": 0, "tokenBalanceChanges": [ { "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "userAccount": "49bNwvPHy3krxWLBU3qrK5Gm1RPFqWDXB8xaawcjyCjd", "tokenAccount": "C2NWfgp2qUb2papQzy28XQPCxeT84zDLSPJRZvXGeZBp", "rawTokenAmount": { "decimals": 6, "tokenAmount": "100" } } ] }, { "account": "C8wu1R39Brrbf8BrKB6uNQbv1pr43RopsQ2LknfdHa6A", "nativeBalanceChange": 0, "tokenBalanceChanges": [ { "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "userAccount": "9Nmpej98v2kEAXtc4AD52EbQAFrc3ikRe881LehqsEHD", "tokenAccount": "C8wu1R39Brrbf8BrKB6uNQbv1pr43RopsQ2LknfdHa6A", "rawTokenAmount": { "decimals": 6, "tokenAmount": "-100" } } ] }, { "account": "ComputeBudget111111111111111111111111111111", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "nativeBalanceChange": 0, "tokenBalanceChanges": [] } ], "description": "9Nmpej98v2kEAXtc4AD52EbQAFrc3ikRe881LehqsEHD transferred 0.0001 USDC to 49bNwvPHy3krxWLBU3qrK5Gm1RPFqWDXB8xaawcjyCjd.", "instructions": [ { "data": "3TQPSi2v9sgs", "accounts": [], "programId": "ComputeBudget111111111111111111111111111111", "innerInstructions": [] }, { "data": "J9y72F", "accounts": [], "programId": "ComputeBudget111111111111111111111111111111", "innerInstructions": [] }, { "data": "hNmtbNYibdzwf", "accounts": [ "C8wu1R39Brrbf8BrKB6uNQbv1pr43RopsQ2LknfdHa6A", "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "C2NWfgp2qUb2papQzy28XQPCxeT84zDLSPJRZvXGeZBp", "9Nmpej98v2kEAXtc4AD52EbQAFrc3ikRe881LehqsEHD", "9Nmpej98v2kEAXtc4AD52EbQAFrc3ikRe881LehqsEHD" ], "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "innerInstructions": [] } ], "tokenTransfers": [ { "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "tokenAmount": 0.0001, "toUserAccount": "49bNwvPHy3krxWLBU3qrK5Gm1RPFqWDXB8xaawcjyCjd", "tokenStandard": "Fungible", "toTokenAccount": "C2NWfgp2qUb2papQzy28XQPCxeT84zDLSPJRZvXGeZBp", "fromUserAccount": "9Nmpej98v2kEAXtc4AD52EbQAFrc3ikRe881LehqsEHD", "fromTokenAccount": "C8wu1R39Brrbf8BrKB6uNQbv1pr43RopsQ2LknfdHa6A" } ], "nativeTransfers": [], "recentBlockhash": "2d7rAqbS4ZFsroMHkxA8kmJdvmoPy4mtZRqCpSNTa1DQ", "transactionError": null } ] }, "metadata": { "isDevnet": false, "eventTypes": ["TRANSFER"], "environmentId": "environmentId" }, "type": "SOLANA_TX_V2" } ``` ### `Solana Token Approvals` Once you configure an alert webhook with `Solana Token Approvals` selected as an event, Portal sends a notification any time a Portal client approves a delegated address to spend an amount of a token on their behalf for the specified chain. Portal delivers one event per subscribed address involved and includes a [`direction`](#direction) field — `OUTBOUND` for the owner approving the delegation, `INBOUND` for the delegate being approved to spend. When a token approval occurs, Portal makes a `POST` request to your configured alert webhook URL with a request body that contains the alert webhook event's details. You will receive 1 alert per subscribed address involved when the transaction is `confirmed`. **Retries:** Solana alert webhook events use exponential backoff with up to 9 retries. See the [retry schedule](#retry-schedule) for details. If no successful response occurs after all retries, the webhook event will be marked `FAILED` and you can re-drive it with [`POST /custodians/me/alerts/webhooks/{alertWebhookId}/delivery-events/{deliveryEventId}/retries`](/api-reference/alert-webhooks/retry-an-alert-webhook-delivery-event). See [Delivery event lifecycle](#delivery-event-lifecycle) for details on how manual retries interact with the automatic retry loop. **Headers:** | Name | Type | Description | | ------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Content-Type` | String | `application/json` | | `X-WEBHOOK-SECRET` | String | You can find the alert webhook secret in the [Portal Admin Dashboard](https://app.portalhq.io) on the `"Webhooks"` page under the `"Alert Webhooks"` section. | **Request body (examples)** ```json theme={null} { "data": { "rawEvents": [ { "fee": 10000, "slot": 438851116, "type": "APPROVE", "events": {}, "source": "SOLANA_PROGRAM_LIBRARY", "feePayer": "98aauPkabddWQVyHWNw5LJLDb84ETKfN2xYUy1Zr2sQy", "signature": "7fLggf2PBHnH8QfWTzWjjpXKcyUj9h2roR6tCBxAAHJoiLyuYEDx95RqJNz1vnjFSJ8vyaZpanCgEHVLdVvX9Ng", "timestamp": 1769837881, "triggeredBy": "J4AVMHNUkxhjUmWg25RTfysAEx7FnRYymT2xZkc12sKK", "direction": "OUTBOUND", "accountData": [ { "account": "98aauPkabddWQVyHWNw5LJLDb84ETKfN2xYUy1Zr2sQy", "nativeBalanceChange": -10000, "tokenBalanceChanges": [] }, { "account": "J4AVMHNUkxhjUmWg25RTfysAEx7FnRYymT2xZkc12sKK", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "pQxS4DANX5uMkDkzJSk2dSBnrEWZ1enUL2XkKF6Nhbh", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "nativeBalanceChange": 0, "tokenBalanceChanges": [] } ], "description": "", "instructions": [ { "data": "3xHiPX82ia9u", "accounts": [ "pQxS4DANX5uMkDkzJSk2dSBnrEWZ1enUL2XkKF6Nhbh", "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "J4AVMHNUkxhjUmWg25RTfysAEx7FnRYymT2xZkc12sKK" ], "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "innerInstructions": [] } ], "tokenTransfers": [], "nativeTransfers": [], "recentBlockhash": "2d7rAqbS4ZFsroMHkxA8kmJdvmoPy4mtZRqCpSNTa1DQ", "transactionError": null } ] }, "metadata": { "isDevnet": true, "eventTypes": ["APPROVE"], "environmentId": "environmentId" }, "type": "SOLANA_APPROVE_V2" } ``` ### `Solana Token Revocations` Once you configure an alert webhook with `Solana Token Revocations` selected as an event, Portal sends a notification any time a Portal client revokes a delegated address's approval to spend an SPL token on their behalf for the specified chain. Portal delivers one event per subscribed address involved and includes a [`direction`](#direction) field, which is always `OUTBOUND` for revocations. When a token revocation occurs, Portal makes a `POST` request to your configured alert webhook URL with a request body that contains the alert webhook event's details. You will receive 1 alert per subscribed address involved when the transaction is `confirmed`. **Retries:** Solana alert webhook events use exponential backoff with up to 9 retries. See the [retry schedule](#retry-schedule) for details. If no successful response occurs after all retries, the webhook event will be marked `FAILED` and you can re-drive it with [`POST /custodians/me/alerts/webhooks/{alertWebhookId}/delivery-events/{deliveryEventId}/retries`](/api-reference/alert-webhooks/retry-an-alert-webhook-delivery-event). See [Delivery event lifecycle](#delivery-event-lifecycle) for details on how manual retries interact with the automatic retry loop. **Headers:** | Name | Type | Description | | ------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Content-Type` | String | `application/json` | | `X-WEBHOOK-SECRET` | String | You can find the alert webhook secret in the [Portal Admin Dashboard](https://app.portalhq.io) on the `"Webhooks"` page under the `"Alert Webhooks"` section. | **Request body (examples)** ```json theme={null} { "data": { "rawEvents": [ { "fee": 10000, "slot": 438851202, "type": "REVOKE", "events": {}, "source": "SOLANA_PROGRAM_LIBRARY", "feePayer": "98aauPkabddWQVyHWNw5LJLDb84ETKfN2xYUy1Zr2sQy", "signature": "3p4qVucEtUhZ3GbsQv3ZQS2mTRsziSgJs6B7gre34X2xnG2WPkaNvHuk9wDRJ2kKzKa8M7Rmykpr6wTtYLRuY9oh", "timestamp": 1769837914, "triggeredBy": "98aauPkabddWQVyHWNw5LJLDb84ETKfN2xYUy1Zr2sQy", "direction": "OUTBOUND", "accountData": [ { "account": "98aauPkabddWQVyHWNw5LJLDb84ETKfN2xYUy1Zr2sQy", "nativeBalanceChange": -10000, "tokenBalanceChanges": [] }, { "account": "J4AVMHNUkxhjUmWg25RTfysAEx7FnRYymT2xZkc12sKK", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "pQxS4DANX5uMkDkzJSk2dSBnrEWZ1enUL2XkKF6Nhbh", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "nativeBalanceChange": 0, "tokenBalanceChanges": [] } ], "description": "", "instructions": [ { "data": "6", "accounts": [ "pQxS4DANX5uMkDkzJSk2dSBnrEWZ1enUL2XkKF6Nhbh", "J4AVMHNUkxhjUmWg25RTfysAEx7FnRYymT2xZkc12sKK" ], "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "innerInstructions": [] } ], "tokenTransfers": [], "nativeTransfers": [], "recentBlockhash": "2d7rAqbS4ZFsroMHkxA8kmJdvmoPy4mtZRqCpSNTa1DQ", "transactionError": null } ] }, "metadata": { "isDevnet": true, "eventTypes": ["REVOKE"], "environmentId": "environmentId" }, "type": "SOLANA_REVOKE_V2" } ``` ### `Solana Delegated Transfers` `Solana Delegated Transfers` triggers when a user performs a delegated transfer using funds from another user who has approved a token delegation to them. This event type is useful for tracking when delegated token allowances are actually spent. For example, if User A approves User B to spend their USDC, and User B later transfers some of User A's USDC, you will receive a `SOLANA_DELEGATED_TRANSFER_V2` event. Each event carries a [`direction`](#direction) field that distinguishes the three roles in a delegated transfer: `OUTBOUND` for the delegate who executed the transfer, `INBOUND` for the recipient of the tokens, and `DELEGATED` for the owner whose tokens were moved. This is the only Solana event where `direction` can be `DELEGATED`. Once you configure an alert webhook with `Solana Delegated Transfers` selected as an event, Portal sends a notification any time a delegated transfer occurs involving your Portal clients. When a delegated transfer occurs, Portal makes a `POST` request to your configured alert webhook URL with a request body that contains the alert webhook event's details. You will receive 1 alert per subscribed address involved when the transaction is `confirmed`. **Retries:** Solana alert webhook events use exponential backoff with up to 9 retries. See the [retry schedule](#retry-schedule) for details. If no successful response occurs after all retries, the webhook event will be marked `FAILED` and you can re-drive it with [`POST /custodians/me/alerts/webhooks/{alertWebhookId}/delivery-events/{deliveryEventId}/retries`](/api-reference/alert-webhooks/retry-an-alert-webhook-delivery-event). See [Delivery event lifecycle](#delivery-event-lifecycle) for details on how manual retries interact with the automatic retry loop. **Headers:** | Name | Type | Description | | ------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Content-Type` | String | `application/json` | | `X-WEBHOOK-SECRET` | String | You can find the alert webhook secret in the [Portal Admin Dashboard](https://app.portalhq.io) on the `"Webhooks"` page under the `"Alert Webhooks"` section. | **Request body (examples)** ```json theme={null} { "data": { "rawEvents": [ { "fee": 10000, "slot": 413227505, "type": "DELEGATED_TRANSFER", "events": {}, "source": "SOLANA_PROGRAM_LIBRARY", "feePayer": "2eWxXEVjmyBxaJCPRmZJqtpYm7wkyXfmcNAwFPHBpwLx", "signature": "5BFuHM8bMUVfHuwFc4DHoPQsYMbwhYWLGkjv41Lu5dn4wuRb23mK5oDJirjQxKadGYYWzTmSYnUMUed3Reef6Mxx", "timestamp": 1776194807, "triggeredBy": "9Nmpej98v2kEAXtc4AD52EbQAFrc3ikRe881LehqsEHD", "direction": "INBOUND", "accountData": [ { "account": "2eWxXEVjmyBxaJCPRmZJqtpYm7wkyXfmcNAwFPHBpwLx", "nativeBalanceChange": -10000, "tokenBalanceChanges": [] }, { "account": "9rG4md1zvwpP1NtjMg48HutvuWYaHXerYat2C2E4PAz7", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "C2NWfgp2qUb2papQzy28XQPCxeT84zDLSPJRZvXGeZBp", "nativeBalanceChange": 0, "tokenBalanceChanges": [ { "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "userAccount": "49bNwvPHy3krxWLBU3qrK5Gm1RPFqWDXB8xaawcjyCjd", "tokenAccount": "C2NWfgp2qUb2papQzy28XQPCxeT84zDLSPJRZvXGeZBp", "rawTokenAmount": { "decimals": 6, "tokenAmount": "-1" } } ] }, { "account": "C8wu1R39Brrbf8BrKB6uNQbv1pr43RopsQ2LknfdHa6A", "nativeBalanceChange": 0, "tokenBalanceChanges": [ { "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "userAccount": "9Nmpej98v2kEAXtc4AD52EbQAFrc3ikRe881LehqsEHD", "tokenAccount": "C8wu1R39Brrbf8BrKB6uNQbv1pr43RopsQ2LknfdHa6A", "rawTokenAmount": { "decimals": 6, "tokenAmount": "1" } } ] }, { "account": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "nativeBalanceChange": 0, "tokenBalanceChanges": [] } ], "description": "9rG4md1zvwpP1NtjMg48HutvuWYaHXerYat2C2E4PAz7 transferred 0.000001 USDC to 9Nmpej98v2kEAXtc4AD52EbQAFrc3ikRe881LehqsEHD.", "instructions": [ { "data": "3DdGGhkhJbjm", "accounts": [ "C2NWfgp2qUb2papQzy28XQPCxeT84zDLSPJRZvXGeZBp", "C8wu1R39Brrbf8BrKB6uNQbv1pr43RopsQ2LknfdHa6A", "9rG4md1zvwpP1NtjMg48HutvuWYaHXerYat2C2E4PAz7" ], "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "innerInstructions": [] } ], "tokenTransfers": [ { "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "tokenAmount": 0.000001, "toUserAccount": "9Nmpej98v2kEAXtc4AD52EbQAFrc3ikRe881LehqsEHD", "tokenStandard": "Fungible", "toTokenAccount": "C8wu1R39Brrbf8BrKB6uNQbv1pr43RopsQ2LknfdHa6A", "fromUserAccount": "9rG4md1zvwpP1NtjMg48HutvuWYaHXerYat2C2E4PAz7", "fromTokenAccount": "C2NWfgp2qUb2papQzy28XQPCxeT84zDLSPJRZvXGeZBp" } ], "nativeTransfers": [], "recentBlockhash": "2d7rAqbS4ZFsroMHkxA8kmJdvmoPy4mtZRqCpSNTa1DQ", "transactionError": null } ] }, "metadata": { "isDevnet": false, "eventTypes": ["DELEGATED_TRANSFER"], "environmentId": "environmentId" }, "type": "SOLANA_DELEGATED_TRANSFER_V2" } ``` ## Retry schedule Each alert type has its own automatic retry policy. The table below summarizes the policy for every event type that ships with delivery events (`PENDING` → `DELIVERED`/`FAILED` lifecycle). Once the automatic retries are exhausted, the delivery event is marked `FAILED` and you can re-drive it manually with [`POST /custodians/me/alerts/webhooks/{alertWebhookId}/delivery-events/{deliveryEventId}/retries`](/api-reference/alert-webhooks/retry-an-alert-webhook-delivery-event) — see [Delivery event lifecycle](#delivery-event-lifecycle). | Event type | Automatic retry policy | | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `EVM Wallet Transactions` (`EIP_155_TX_V1`) | Up to 9 retries with exponential backoff. See the [backoff schedule](#backoff-schedule) below. | | `Solana Wallet Transactions` (`SOLANA_TX_V2`) | Up to 9 retries with exponential backoff. See the [backoff schedule](#backoff-schedule) below. | | `Solana Token Approvals` (`SOLANA_APPROVE_V2`) | Up to 9 retries with exponential backoff. See the [backoff schedule](#backoff-schedule) below. | | `Solana Token Revocations` (`SOLANA_REVOKE_V2`) | Up to 9 retries with exponential backoff. See the [backoff schedule](#backoff-schedule) below. | | `Solana Delegated Transfers` (`SOLANA_DELEGATED_TRANSFER_V2`) | Up to 9 retries with exponential backoff. See the [backoff schedule](#backoff-schedule) below. | | `Solana Wallet Transactions (Legacy)` (`SOLANA_TX_V1`) | Up to 9 retries with exponential backoff. See the [backoff schedule](#backoff-schedule) below. | | `Solana Token Approvals (Legacy)` (`SOLANA_APPROVE_V1`) | Up to 9 retries with exponential backoff. See the [backoff schedule](#backoff-schedule) below. | | `Solana Token Revocations (Legacy)` (`SOLANA_REVOKE_V1`) | Up to 9 retries with exponential backoff. See the [backoff schedule](#backoff-schedule) below. | | `Wallet Created` (`WALLET_GENERATE_V1`) | Up to 9 retries with exponential backoff. See the [backoff schedule](#backoff-schedule) below. | | `Wallet Backed Up` (`WALLET_BACKUP_V1`) | Up to 9 retries with exponential backoff. See the [backoff schedule](#backoff-schedule) below. | | `Wallet Recovered` (`WALLET_RECOVER_V1`) | Up to 9 retries with exponential backoff. See the [backoff schedule](#backoff-schedule) below. | | `Wallet Ejected` (`WALLET_EJECT_V1`) | Single synchronous attempt. No automatic retries. | | `Signature Approvals` (`PRE_SIGN_V1`) | Single synchronous attempt. No automatic retries — the signing decision is made from that one response. | ### Backoff schedule All EVM, Solana V2, and legacy Solana V1 alert types share the same exponential backoff: | Retry | Backoff | | ----- | ---------- | | 1 | 4 minutes | | 2 | 8 minutes | | 3 | 15 minutes | | 4 | 30 minutes | | 5 | 1 hour | | 6 | 2 hours | | 7 | 4 hours | | 8 | 8 hours | | 9 | 16 hours | ## Solana Alert Webhook Events (Legacy) Already integrated against the V1 events below? Follow the [migration guide for legacy Solana alert webhook events](/resources/migrating-from-solana-legacy-webhooks) to move to the current alerts and to subscribe to the new `Solana Delegated Transfers` event. ### Why migrate from V1 to V2? We recommend moving from V1 to the current Solana alert webhook events. V1 will be deprecated in the near future, and the current events give you per-address delivery (with a `triggeredBy` field for routing), a `direction` field for parsing the role of each address, and the new `Solana Delegated Transfers` event for tracking delegate spends. See the [migration guide for legacy Solana alert webhook events](/resources/migrating-from-solana-legacy-webhooks) for the cutover steps. | | V1 | V2 | | -------------------------- | --------------------------------- | ------------------------------------------------------------------------------------ | | **Delivery model** | One event per Solana transaction | One event per subscribed address involved in the transaction | | **Address identification** | No `triggeredBy` field | Includes `triggeredBy` — the address that triggered the event | | **Direction parsing** | No `direction` field | Includes `direction` (`OUTBOUND`, `INBOUND`, or `DELEGATED`) | | **Delegated transfers** | Not delivered as a separate event | New `Solana Delegated Transfers` event when a delegate spends an approved allowance | | **More data included** | No `recentBlockhash` field | Includes `recentBlockhash` — the base58-encoded `recentBlockhash` of the transaction | | **Event type** | `SOLANA_TX_V1` | `SOLANA_TX_V2` | In V1, if a transaction involves two of your Portal clients (e.g. one sends SOL to the other), you receive a single webhook event for that transaction. In V2, you receive one event per subscribed address involved — so you would receive two separate events for the same transaction, each with a different `triggeredBy` value. You can use the `triggeredBy` field along with the transaction `signature` to deduplicate events on your end if needed. ### `Solana Wallet Transactions (Legacy)` **Deprecation notice:** `Solana Wallet Transactions` (V1) will be deprecated in the near future. We recommend [migrating from the legacy Solana alert webhook events](/resources/migrating-from-solana-legacy-webhooks) as soon as possible. V2 delivers one event per subscribed address involved in the transaction (instead of one event per transaction) and includes a `triggeredBy` field for easier deduplication. Once you set up an alert webhook with `Solana Wallet Transactions` as the event, Portal begins monitoring *transfer* transactions for your Portal clients with a Solana address. Any new client created with a Solana address will also trigger notifications for their on-chain transfers. When a Solana transfer transaction occurs, Portal makes a `POST` request to your configured alert webhook URL with a request body that contains the alert webhook event's details. You will receive 1 alert when the transaction is `confirmed`. `Solana Wallet Transactions` currently sends alerts with the following use cases: 1. Receive native tokens (ie. `SOL`) from another address 2. Send native tokens (ie. `SOL`) to another address 3. Receive non-native tokens (ie. `USDC`) from another address 4. Send non-native tokens (ie. `USDC`) to another address Solana alert webhook events support **up to 100,000 addresses**. Contact our team if you require additional capacity. After you create a new Portal client wallet, it can take **up to 4 minutes** before you start to receive Solana transaction events for them. **Retries:** When your webhook does not respond to a request, or if you do not respond with a `2XX` status code, Portal will retry the request up to 9 times with exponential backoff (see the [retry schedule](#retry-schedule)). If no successful response occurs after all retries, the webhook event will be marked `FAILED` and you can re-drive it with [`POST /custodians/me/alerts/webhooks/{alertWebhookId}/delivery-events/{deliveryEventId}/retries`](/api-reference/alert-webhooks/retry-an-alert-webhook-delivery-event). See [Delivery event lifecycle](#delivery-event-lifecycle) for details on how manual retries interact with the automatic retry loop. **Headers:** | Name | Type | Description | | ------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Content-Type` | String | `application/json` | | `X-WEBHOOK-SECRET` | String | You can find the alert webhook secret in the [Portal Admin Dashboard](https://app.portalhq.io) on the `"Webhooks"` page under the `"Alert Webhooks"` section. | **Request body (examples)** ```json theme={null} { "data": { "rawEvents": [ { "fee": 80000, "slot": 379135212, "type": "TRANSFER", "events": {}, "source": "SYSTEM_PROGRAM", "feePayer": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "signature": "5hyWP82Fn951zjo5DHWAvAC8NjwU7LAKqR3Rpn7sB1H159koer8BefYSSCRuC7X3xVy6rHvMzM7sYHLBwxTAEeUY", "timestamp": 1746565669, "accountData": [ { "account": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "nativeBalanceChange": -10080000, "tokenBalanceChanges": [] }, { "account": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "nativeBalanceChange": 10000000, "tokenBalanceChanges": [] }, { "account": "11111111111111111111111111111111", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "ComputeBudget111111111111111111111111111111", "nativeBalanceChange": 0, "tokenBalanceChanges": [] } ], "description": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H transferred 0.01 SOL to 67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx.", "instructions": [ { "data": "3b1H8Rq1T3d1", "accounts": [], "programId": "ComputeBudget111111111111111111111111111111", "innerInstructions": [] }, { "data": "LKoyXd", "accounts": [], "programId": "ComputeBudget111111111111111111111111111111", "innerInstructions": [] }, { "data": "3Bxs4NN8M2Yn4TLb", "accounts": [ "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx" ], "programId": "11111111111111111111111111111111", "innerInstructions": [] } ], "tokenTransfers": [], "nativeTransfers": [ { "amount": 10000000, "toUserAccount": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "fromUserAccount": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H" } ], "transactionError": null } ] }, "metadata": { "isDevnet": true, "eventTypes": ["TRANSFER"], "environmentId": "environmentId" }, "type": "SOLANA_TX_V1" } ``` ```json theme={null} { "data": { "rawEvents": [ { "fee": 5000, "slot": 379135804, "type": "TRANSFER", "events": {}, "source": "SYSTEM_PROGRAM", "feePayer": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "signature": "KENcRJpHNowJcpDwPvB2yR6VnDzMbQu5Nd1om5K1Y3cjdS3ujStBWYtz3EMhY1UvzykShhdZSUShwZrmYWbxfAF", "timestamp": 1746565903, "accountData": [ { "account": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "nativeBalanceChange": -205000, "tokenBalanceChanges": [] }, { "account": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "nativeBalanceChange": 200000, "tokenBalanceChanges": [] }, { "account": "11111111111111111111111111111111", "nativeBalanceChange": 0, "tokenBalanceChanges": [] } ], "description": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx transferred 0.0002 SOL to 75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H.", "instructions": [ { "data": "3Bxs4Ba2u7BkmhYB", "accounts": [ "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H" ], "programId": "11111111111111111111111111111111", "innerInstructions": [] } ], "tokenTransfers": [], "nativeTransfers": [ { "amount": 200000, "toUserAccount": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "fromUserAccount": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx" } ], "transactionError": null } ] }, "metadata": { "isDevnet": true, "eventTypes": ["TRANSFER"], "environmentId": "environmentId" }, "type": "SOLANA_TX_V1" } ``` ```json theme={null} { "data": { "rawEvents": [ { "fee": 80000, "slot": 379136813, "type": "TRANSFER", "events": {}, "source": "SOLANA_PROGRAM_LIBRARY", "feePayer": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "signature": "3AH7LPWnzvqmQRkGWMECe7Hxmi6M4FurCrGxAfS4JP7YNVz81qQhwsX7YmHdQxA1bfGFPDc8ouHXuhncXPnT3Uf9", "timestamp": 1746566300, "accountData": [ { "account": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "nativeBalanceChange": -2119280, "tokenBalanceChanges": [] }, { "account": "4hi76XkyLMVtuPunTUH8SMYGbKshhcGufSfZEUkWvj9v", "nativeBalanceChange": 2039280, "tokenBalanceChanges": [ { "mint": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", "userAccount": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "tokenAccount": "4hi76XkyLMVtuPunTUH8SMYGbKshhcGufSfZEUkWvj9v", "rawTokenAmount": { "decimals": 6, "tokenAmount": "100000" } } ] }, { "account": "CoDtJspas5BkRaxA8GTrzLZGTAMcsoec4oTrrVjwrJqA", "nativeBalanceChange": 0, "tokenBalanceChanges": [ { "mint": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", "userAccount": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "tokenAccount": "CoDtJspas5BkRaxA8GTrzLZGTAMcsoec4oTrrVjwrJqA", "rawTokenAmount": { "decimals": 6, "tokenAmount": "-100000" } } ] }, { "account": "11111111111111111111111111111111", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "ComputeBudget111111111111111111111111111111", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "SysvarRent111111111111111111111111111111111", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "nativeBalanceChange": 0, "tokenBalanceChanges": [] } ], "description": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H transferred 0.1 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU to 67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx.", "instructions": [ { "data": "3pyz49iBVPDH", "accounts": [], "programId": "ComputeBudget111111111111111111111111111111", "innerInstructions": [] }, { "data": "Ejyj83", "accounts": [], "programId": "ComputeBudget111111111111111111111111111111", "innerInstructions": [] }, { "data": "", "accounts": [ "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "4hi76XkyLMVtuPunTUH8SMYGbKshhcGufSfZEUkWvj9v", "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", "11111111111111111111111111111111", "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "SysvarRent111111111111111111111111111111111" ], "programId": "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", "innerInstructions": [ { "data": "84eT", "accounts": [ "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" ], "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" }, { "data": "11119os1e9qSs2u7TsThXqkBSRVFxhmYaFKFZ1waB2X7armDmvK3p5GmLdUxYdg3h7QSrL", "accounts": [ "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "4hi76XkyLMVtuPunTUH8SMYGbKshhcGufSfZEUkWvj9v" ], "programId": "11111111111111111111111111111111" }, { "data": "P", "accounts": [ "4hi76XkyLMVtuPunTUH8SMYGbKshhcGufSfZEUkWvj9v" ], "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" }, { "data": "6SFd4LwbEeGhbq7iC4apSEA85UYCPWD7DUnLboAaBWSQv", "accounts": [ "4hi76XkyLMVtuPunTUH8SMYGbKshhcGufSfZEUkWvj9v", "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" ], "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" } ] }, { "data": "i9TTqffgKmDLh", "accounts": [ "CoDtJspas5BkRaxA8GTrzLZGTAMcsoec4oTrrVjwrJqA", "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", "4hi76XkyLMVtuPunTUH8SMYGbKshhcGufSfZEUkWvj9v", "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H" ], "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "innerInstructions": [] } ], "tokenTransfers": [ { "mint": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", "tokenAmount": 0.1, "toUserAccount": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "tokenStandard": "Fungible", "toTokenAccount": "4hi76XkyLMVtuPunTUH8SMYGbKshhcGufSfZEUkWvj9v", "fromUserAccount": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "fromTokenAccount": "CoDtJspas5BkRaxA8GTrzLZGTAMcsoec4oTrrVjwrJqA" } ], "nativeTransfers": [ { "amount": 2039280, "toUserAccount": "4hi76XkyLMVtuPunTUH8SMYGbKshhcGufSfZEUkWvj9v", "fromUserAccount": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H" } ], "transactionError": null } ] }, "metadata": { "isDevnet": true, "eventTypes": ["TRANSFER"], "environmentId": "environmentId" }, "type": "SOLANA_TX_V1" } ``` ```json theme={null} { "data": { "rawEvents": [ { "fee": 5000, "slot": 379137198, "type": "TRANSFER", "events": {}, "source": "SOLANA_PROGRAM_LIBRARY", "feePayer": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "signature": "yqQnyoqB1cm5se1U4Da6Hr89mNvozzp5WULtkNSUP3jVH5c79whZ9zg2NXeQhV3c3wkmsVjPV627WZtfCaAeRJW", "timestamp": 1746566451, "accountData": [ { "account": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "nativeBalanceChange": -5000, "tokenBalanceChanges": [] }, { "account": "4hi76XkyLMVtuPunTUH8SMYGbKshhcGufSfZEUkWvj9v", "nativeBalanceChange": 0, "tokenBalanceChanges": [ { "mint": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", "userAccount": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "tokenAccount": "4hi76XkyLMVtuPunTUH8SMYGbKshhcGufSfZEUkWvj9v", "rawTokenAmount": { "decimals": 6, "tokenAmount": "-10000" } } ] }, { "account": "CoDtJspas5BkRaxA8GTrzLZGTAMcsoec4oTrrVjwrJqA", "nativeBalanceChange": 0, "tokenBalanceChanges": [ { "mint": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", "userAccount": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "tokenAccount": "CoDtJspas5BkRaxA8GTrzLZGTAMcsoec4oTrrVjwrJqA", "rawTokenAmount": { "decimals": 6, "tokenAmount": "10000" } } ] }, { "account": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "nativeBalanceChange": 0, "tokenBalanceChanges": [] } ], "description": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx transferred 0.01 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU to 75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H.", "instructions": [ { "data": "3GAG5eogvTjV", "accounts": [ "4hi76XkyLMVtuPunTUH8SMYGbKshhcGufSfZEUkWvj9v", "CoDtJspas5BkRaxA8GTrzLZGTAMcsoec4oTrrVjwrJqA", "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx" ], "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "innerInstructions": [] } ], "tokenTransfers": [ { "mint": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", "tokenAmount": 0.01, "toUserAccount": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "tokenStandard": "Fungible", "toTokenAccount": "CoDtJspas5BkRaxA8GTrzLZGTAMcsoec4oTrrVjwrJqA", "fromUserAccount": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "fromTokenAccount": "4hi76XkyLMVtuPunTUH8SMYGbKshhcGufSfZEUkWvj9v" } ], "nativeTransfers": [], "transactionError": null } ] }, "metadata": { "isDevnet": true, "eventTypes": ["TRANSFER"], "environmentId": "environmentId" }, "type": "SOLANA_TX_V1" } ``` ### `Solana Token Approvals (Legacy)` **Deprecation notice:** `Solana Token Approvals` (V1) will be deprecated in the near future. We recommend [migrating from the legacy Solana alert webhook events](/resources/migrating-from-solana-legacy-webhooks) as soon as possible. Once you configure an alert webhook with `Solana Token Approvals` selected as an event, Portal sends a notification any time a Portal client approves a delegated address to spend an amount of a token on their behalf for the specified chain. When a token approval occurs, Portal makes a `POST` request to your configured alert webhook URL with a request body that contains the alert webhook event's details. You will receive 1 alert when the transaction is `confirmed`. **Retries:** When your webhook does not respond to a request, or if you do not respond with a `2XX` status code, Portal will retry the request up to 9 times with exponential backoff (see the [retry schedule](#retry-schedule)). If no successful response occurs after all retries, the webhook event will be marked `FAILED` and you can re-drive it with [`POST /custodians/me/alerts/webhooks/{alertWebhookId}/delivery-events/{deliveryEventId}/retries`](/api-reference/alert-webhooks/retry-an-alert-webhook-delivery-event). See [Delivery event lifecycle](#delivery-event-lifecycle) for details on how manual retries interact with the automatic retry loop. **Headers:** | Name | Type | Description | | ------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Content-Type` | String | `application/json` | | `X-WEBHOOK-SECRET` | String | You can find the alert webhook secret in the [Portal Admin Dashboard](https://app.portalhq.io) on the `"Webhooks"` page under the `"Alert Webhooks"` section. | **Request body (examples)** ```json theme={null} { "data": { "rawEvents": [ { "fee": 10000, "slot": 438851116, "type": "APPROVE", "events": {}, "source": "SOLANA_PROGRAM_LIBRARY", "feePayer": "98aauPkabddWQVyHWNw5LJLDb84ETKfN2xYUy1Zr2sQy", "signature": "7fLggf2PBHnH8QfWTzWjjpXKcyUj9h2roR6tCBxAAHJoiLyuYEDx95RqJNz1vnjFSJ8vyaZpanCgEHVLdVvX9Ng", "timestamp": 1769837881, "accountData": [ { "account": "98aauPkabddWQVyHWNw5LJLDb84ETKfN2xYUy1Zr2sQy", "nativeBalanceChange": -10000, "tokenBalanceChanges": [] }, { "account": "J4AVMHNUkxhjUmWg25RTfysAEx7FnRYymT2xZkc12sKK", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "pQxS4DANX5uMkDkzJSk2dSBnrEWZ1enUL2XkKF6Nhbh", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "nativeBalanceChange": 0, "tokenBalanceChanges": [] } ], "description": "", "instructions": [ { "data": "3xHiPX82ia9u", "accounts": [ "pQxS4DANX5uMkDkzJSk2dSBnrEWZ1enUL2XkKF6Nhbh", "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "J4AVMHNUkxhjUmWg25RTfysAEx7FnRYymT2xZkc12sKK" ], "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "innerInstructions": [] } ], "tokenTransfers": [], "nativeTransfers": [], "transactionError": null } ] }, "metadata": { "isDevnet": true, "eventTypes": ["APPROVE"], "environmentId": "environmentId" }, "type": "SOLANA_APPROVAL_V1" } ``` ### `Solana Token Revocations (Legacy)` **Deprecation notice:** `Solana Token Revocations` (V1) will be deprecated in the near future. We recommend [migrating from the legacy Solana alert webhook events](/resources/migrating-from-solana-legacy-webhooks) as soon as possible. Once you configure an alert webhook with `Solana Token Revocations` selected as an event, Portal sends a notification any time a Portal client revokes a delegated address's approval to spend an SPL token on their behalf for the specified chain. When a token revocation occurs, Portal makes a `POST` request to your configured alert webhook URL with a request body that contains the alert webhook event's details. You will receive 1 alert when the transaction is `confirmed`. **Retries:** When your webhook does not respond to a request, or if you do not respond with a `2XX` status code, Portal will retry the request up to 9 times with exponential backoff (see the [retry schedule](#retry-schedule)). If no successful response occurs after all retries, the webhook event will be marked `FAILED` and you can re-drive it with [`POST /custodians/me/alerts/webhooks/{alertWebhookId}/delivery-events/{deliveryEventId}/retries`](/api-reference/alert-webhooks/retry-an-alert-webhook-delivery-event). See [Delivery event lifecycle](#delivery-event-lifecycle) for details on how manual retries interact with the automatic retry loop. **Headers:** | Name | Type | Description | | ------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Content-Type` | String | `application/json` | | `X-WEBHOOK-SECRET` | String | You can find the alert webhook secret in the [Portal Admin Dashboard](https://app.portalhq.io) on the `"Webhooks"` page under the `"Alert Webhooks"` section. | **Request body (examples)** ```json theme={null} { "data": { "rawEvents": [ { "fee": 10000, "slot": 438851202, "type": "REVOKE", "events": {}, "source": "SOLANA_PROGRAM_LIBRARY", "feePayer": "98aauPkabddWQVyHWNw5LJLDb84ETKfN2xYUy1Zr2sQy", "signature": "3p4qVucEtUhZ3GbsQv3ZQS2mTRsziSgJs6B7gre34X2xnG2WPkaNvHuk9wDRJ2kKzKa8M7Rmykpr6wTtYLRuY9oh", "timestamp": 1769837914, "accountData": [ { "account": "98aauPkabddWQVyHWNw5LJLDb84ETKfN2xYUy1Zr2sQy", "nativeBalanceChange": -10000, "tokenBalanceChanges": [] }, { "account": "J4AVMHNUkxhjUmWg25RTfysAEx7FnRYymT2xZkc12sKK", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "pQxS4DANX5uMkDkzJSk2dSBnrEWZ1enUL2XkKF6Nhbh", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "nativeBalanceChange": 0, "tokenBalanceChanges": [] } ], "description": "", "instructions": [ { "data": "6", "accounts": [ "pQxS4DANX5uMkDkzJSk2dSBnrEWZ1enUL2XkKF6Nhbh", "J4AVMHNUkxhjUmWg25RTfysAEx7FnRYymT2xZkc12sKK" ], "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "innerInstructions": [] } ], "tokenTransfers": [], "nativeTransfers": [], "transactionError": null } ] }, "metadata": { "isDevnet": true, "eventTypes": ["REVOKE"], "environmentId": "environmentId" }, "type": "SOLANA_REVOKE_V1" } ``` ## Security Here are a few considerations to ensure your alert webhooks are implemented securely: * Alert webhook URLs must use `HTTPS`. * Verify that each alert webhook request has the expected `X-WEBHOOK-SECRET` header value. You can find the secret for your alert webhook in the [Portal Admin Dashboard](https://app.portalhq.io). * Restricting requests on your alert webhook server to only those from Portal's IP addresses protects against requests from other parties. Configure your alert webhook server to only accept inbound connections from our IP addresses. Portal always makes requests from the IP addresses `35.203.150.117`, `104.155.171.139` or `35.185.20.23`. ## Example Implementation We provide a [reference implementation](https://github.com/portal-hq/portal-alert-webhooks-example) of alert webhooks using TypeScript and Express. This example demonstrates best practices for handling alert webhook events, including: * 🔒 IP address verification * 🔑 Webhook secret validation * ⚡ Async event processing You can use this as a starting point for your own implementation: ```bash theme={null} git clone https://github.com/portal-hq/portal-alert-webhooks-example cd portal-alert-webhooks-example npm install ``` ## FAQ ### What if I miss an alert webhook event? * If your alert webhook is down or is not responding with `2xx` status codes, Portal will retry sending the alert webhook event using an exponential backoff with up to 9 retries. See the [retry schedule](#retry-schedule) for details. * You can inspect and re-drive delivery attempts using the new delivery-event endpoints: * List all of your alert webhooks with [`GET /custodians/me/alerts/webhooks`](/api-reference/alert-webhooks/list-alert-webhooks). * List the delivery events for a specific alert webhook with [`GET /custodians/me/alerts/webhooks/{alertWebhookId}/delivery-events`](/api-reference/alert-webhooks/list-alert-webhook-delivery-events). Filter by `statuses=FAILED` to find events that need attention, or by `statuses=PENDING` to see events still waiting on the automatic retry loop. * Re-drive a single delivery event with [`POST /custodians/me/alerts/webhooks/{alertWebhookId}/delivery-events/{deliveryEventId}/retries`](/api-reference/alert-webhooks/retry-an-alert-webhook-delivery-event). This works on `FAILED` events and also on `PENDING` events — but note that retrying a `PENDING` event removes it from the automatic retry loop. See [Delivery event lifecycle](#delivery-event-lifecycle) for the full state machine. ### Should I process webhook events before responding? No. Please **acknowledge webhooks as quickly as possible**. If you need to process the alert webhook event you receive, process it *after* responding to Portal with a `2xx` status code. (We only wait up to 10 seconds to receive a response before considering the alert webhook event's delivery as `failed`.) ### Why am I receiving two events with the same `from` and `to` for the same transaction? This is expected when **both** the sender and the receiver are addresses you've registered with Portal (e.g. two of your Portal clients, or a Portal client and one of your [external addresses](#external-addresses)). A single on-chain transfer produces an `OUTBOUND` event from the sender's perspective and an `INBOUND` event from the receiver's perspective. Both events describe the same on-chain movement, so the `from` and `to` fields are identical — those fields reflect the actual on-chain direction (who sent, who received), not the perspective of the subscribed wallet. Use the `direction` and `triggeredBy` fields to distinguish the two. For EVM events, use `data[].metadata.triggeredBy`; for Solana events, use `data.rawEvents[].triggeredBy`. Correlate the pair by `transactionHash` + `blockNumber` (EVM) or `signature` (Solana). See [Dual events for internal transfers](#dual-events-for-internal-transfers) for full details and example payloads. ## Support And that's it! 🎉 You've now implemented alert webhooks and are receiving realtime wallet notifications for your Portal clients! As always, please reach out to our team if you have any questions. # Authentication and API Keys Source: https://docs.portalhq.io/resources/authentication-and-api-keys This guide will walk you through the two different credentials you need to work with Portal. There are two different types of authentication used with Portal integration: a **Portal API Key** for server side requests and a **Client Session Token** (also shown as Client API Key) for authenticating SDK users. The **Portal API Key** is used server side to create new Client Session Tokens, fetch information about clients, or access other endpoints in the [Custodian API](../apis/custodian/api-reference-link). The **Client API Key** is used to initialize the Portal SDKs in order to authenticate an end user ("client"). It can also be used with the Enclave MPC API or other endpoints in the [Client API](../apis/client/api-reference-link). Reach out to us on [Slack](https://join.slack.com/t/portalcommunity/shared_invite/zt-1zlyexf43-A94Z4ugntrgLGs2j5slmeQ) to get access to the Admin Dashboard and start developing! This page covers the credentials you manage yourself. Portal can also authenticate your end users for you, with an email magic link, Google, or Apple, and issue the Client Session Token as part of that sign-in. See [Authentication](/resources/authentication/overview). ## Portal API Keys This is the API key used by your server to make requests to Portal. ### Creating a Portal API Key 1. Log in to [app.portalhq.io](http://app.portalhq.io/). 2. Navigate to Settings -> Custodian API Key. 3. Press "New" to create a new Portal API Key. Before going live with real users, always ensure that you create clients using a Portal API Key from your Portal's **Production** environment. Read more [here](./going-to-production) on going live with real users. ## Client Session Tokens Users (clients) are required to authenticate their devices to Portal in order to create wallets and submit transactions. **Client Session Tokens** **(CST)** are *short-lived*, *auto-refreshing* tokens used to authenticate users to Portal. **Short-Lived**\ CSTs expire after 24 hours of no activity. After this, a new CST must be requested from your backend. **Auto-Refreshing**\ Every authenticated request with a CST updates the token's expiry to 24 hours from that request time. There's a max refresh duration of 7 days, after which a CST can no longer be auto-refreshed. The goal of this system is keep sessions short (reducing attack windows) while minimizing the number of requests needed to create sessions. #### In Practice: * **Active User**: If a user uses Portal daily, their CST expiry extends by 24 hours each day. They'll need a new CST from your backend only once per week (after hitting the 7-day max refresh duration). * **Inactive User**: Users logging in after a few days or weeks will need a new CST from your backend on every login since CSTs expire after 24 hours. ### Creating a Test Client API Key If you'd like to quickly test out a Portal SDK or the [Enclave MPC API](../apis/enclave-mpc/guide/create-a-client) you can generate a Client *API Key* from the Portal Admin dashboard! It has the same permissions as a Client Session Token, except that it doesn't expire. This is helpful for development! Client API Keys are associated with a single wallet! So you will need to create multiple test credentials if you want to generate more than one wallet. 1. Log in to [app.portalhq.io](http://app.portalhq.io/). 2. Navigate to Settings -> Test Client API Keys. 3. Press "New" to create a new Client API Key. ### Creating Client Session Tokens in your Application To authenticate mobile users with Portal, they need a unique Client Session Token. Request this token from your backend using the Portal API Key, then share it with mobile users after they log in. #### Initial Registration When a user first registers for your mobile app, use your Portal API Key to create a Client Session Token. Remember to associate the returned `clientId` with the user in your system. ```typescript theme={null} const response = await axios.post( `https://api.portalhq.io/api/v1/custodians/clients`, {}, { headers: { Authorization: `Bearer ${}` } } ); const clientSessionToken = response.data.clientSessionToken; const clientId = response.data.id; ``` Ensure the **`clientId`** is stored securely as it is crucial for future operations. #### Subsequent Logins When the token needs refreshing (during later logins), use the following endpoint: ```typescript theme={null} const response = await axios.post( `https://api.portalhq.io/api/v1/custodians/clients/${clientId}/session`, {}, { headers: { Authorization: `Bearer ${}` } } ); const refreshedClientSessionToken = response.data.clientSessionToken; ``` Your user can now use this refreshed `clientSessionToken`. When your user's CST expires, all Portal SDKs will throw an error on the next MPC Operation the user makes (e.g. creating a wallet, backing up a wallet, recovering a wallet, or signing). That error will include [the error code **`INVALID_API_KEY`**](./error-codes) in the SDK methods, which you can use as an indicator to refresh your CST. # Apple OAuth Source: https://docs.portalhq.io/resources/authentication/apple-oauth Set up Sign in with Apple, register Portal's return URL, and let your users sign in with their Apple account. Portal identifies an End User by their email address, and Sign in with Apple lets users hide theirs. When an End User picks **Hide My Email**, Apple gives Portal a private relay address such as `abc123@privaterelay.appleid.com` instead of their real one, and that relay address is what Portal stores. The consequence is that the same person is two separate end users, with two separate wallets, if they sign in with Apple using a relay address and with another method using their real address. Read [How Apple handles email addresses](#how-apple-handles-email-addresses) before you offer Apple alongside Google or magic links. ## Step 1: Find your Team ID Sign in to the [Apple Developer](https://developer.apple.com/account) portal. Your **Team ID** is shown in the Membership details section. It is a ten-character value such as `A1B2C3D4E5`. ## Step 2: Create an App ID 1. Go to **Certificates, Identifiers & Profiles > Identifiers**. 2. Add an identifier and choose **App IDs**, then **App**. 3. Set a description and a **Bundle ID** in reverse-domain form, for example `com.example.app`. 4. Under **Capabilities**, enable **Sign in with Apple**. 5. Save. The Bundle ID is the value you will enter into Portal as the **App ID**. ## Step 3: Create a Services ID The Services ID is the identifier Apple treats as the OAuth client for web sign-in. It is a separate identifier from the App ID. 1. In **Identifiers**, add another identifier and choose **Services IDs**. Apple Developer register a new identifier screen with Services IDs selected 2. Set a description and an identifier, for example `com.example.auth`. This is the value you will enter into Portal as the **Service ID**. 3. Save, then reopen the Services ID and enable **Sign in with Apple**. 4. Click **Configure** and set: * **Primary App ID**: the App ID from step 2. * **Domains and Subdomains**: `api.portalhq.io` * **Return URLs**: the Portal callback URL for each environment, as described in [The return URL Apple needs](#the-return-url-apple-needs). Sign in with Apple web configuration modal showing Primary App ID, Domains and Subdomains, and Return URLs 5. Save. ### The return URL Apple needs The Apple OAuth process should redirect to **Portal**, which verifies the sign-in with Apple and then redirects the user on to your Redirect URL. The return URL you register with Apple is therefore a Portal URL: ``` https://api.portalhq.io/api/v3/auth/oauth/callback/ ``` Replace `` with the Auth Environment ID from **Authentication > Configure**. Each environment has a different Auth Environment ID, so **register a return URL for each environment** you plan to use. Apple requires the return URL to match exactly. No trailing slash, `https` only, and the Auth Environment ID has to be correct. A mismatch fails the sign-in before the user returns to your app. ## Step 4: Create a Sign in with Apple key 1. Go to **Certificates, Identifiers & Profiles > Keys**. 2. Add a key, give it a name, and enable **Sign in with Apple**. 3. Click **Configure** and select the App ID from step 2 as the primary App ID. 4. Register the key and download the `.p8` file. 5. Note the **Key ID** shown on the key's page. The `.p8` file can only be downloaded once. Store it somewhere safe. If you lose it you have to revoke the key and create a new one. ## Step 5: Enter the credentials in Portal **Information from Apple (to enter in Portal):** * **Team ID**: your ten-character Apple team identifier * **App ID**: the Bundle ID from step 2, for example `com.example.app` * **Service ID**: the Services ID from step 3, for example `com.example.auth` * **Key ID**: the identifier of the key from step 4 * **Private key**: the full contents of the `.p8` file 1. In the dashboard, click **Configure** in the left sidebar, under Authentication, and select the environment. 2. In the **Apple OAuth** section, turn on the toggle. 3. Fill in all five fields and save. Apple OAuth section with the enable toggle and the Team ID, App ID, Service ID, Key ID, and Private key fields Paste the private key exactly as it appears in the `.p8` file, including the `-----BEGIN PRIVATE KEY-----` and `-----END PRIVATE KEY-----` lines. All five fields are write-only. After saving, each shows that a value is stored rather than the value itself. To replace one, type the new value and save. To leave a stored value alone, leave its field empty. ## Step 6: Sign a user in ### Get the authorize URL ```bash theme={null} curl --request GET \ --url 'https://api.portalhq.io/api/v3/auth/oauth/urls?redirectUrl=https%3A%2F%2Fexample.com%2Fcallback' \ --header 'x-portal-auth-environment-id: ' ``` ```json theme={null} { "data": { "apple": "https://appleid.apple.com/auth/authorize?client_id=com.example.auth&..." }, "metadata": null } ``` The response contains an `apple` key only if Apple is enabled on the environment. `redirectUrl` must be URL-encoded and must exactly match one of your Redirect URLs. To create the user's client with gas sponsorship, add `isAccountAbstracted` to the query string: ```bash theme={null} curl --request GET \ --url 'https://api.portalhq.io/api/v3/auth/oauth/urls?redirectUrl=https%3A%2F%2Fexample.com%2Fcallback&isAccountAbstracted=true' \ --header 'x-portal-auth-environment-id: ' ``` Query parameters are strings, so send the literal `true` or `false`. Omit it to use your organization's default. See [Gas sponsorship](/resources/authentication/overview#gas-sponsorship). ### Send the user to Apple Open `data.apple` in a browser. The user signs in and consents, choosing whether to share their real email address or use Apple's private relay. Apple posts the result to Portal, Portal verifies it, and Portal redirects the user to your Redirect URL: ``` https://example.com/callback?token=&login_type=APPLE ``` ### Exchange the token ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/auth/oauth/tokens \ --header 'x-portal-auth-environment-id: ' \ --header 'Content-Type: application/json' \ --data '{ "token": "" }' ``` ```json theme={null} { "data": { "endUserId": "clx1enduser00000000000000", "clientId": "clx1client00000000000000", "clientSessionToken": "b7c1f0a2-3d4e-5f60-8a91-2b3c4d5e6f70", "isAccountAbstracted": false, "userJwt": null, "totpLink": null }, "metadata": null } ``` `clientId` and `isAccountAbstracted` describe the client Portal created or reused for this user. If the environment requires a second factor, `userJwt` is set instead and `clientId`, `clientSessionToken`, and `isAccountAbstracted` are all `null`. They arrive with the TOTP validation response once the user enters their code. See [Two-factor authentication](/resources/authentication/two-factor-authentication). ## Handle a failed sign-in If the user cancels, or the exchange fails, Portal redirects them to your Redirect URL with an error instead of a token: ``` https://example.com/callback?error=oauth_failed ``` Check for `error` before looking for `token` on your callback page, and offer the user another attempt. ## How Apple handles email addresses Portal identifies an end user by email address, and Apple's behaviour around email is worth knowing about. * Apple only returns the user's email on their **first** consent for a given Services ID. Portal records it then and recognizes the user by their Apple account on later sign-ins. * If Apple does not supply an email, Portal cannot create the end user and the sign-in fails. In practice this happens when a user has previously consented under a different configuration. * If the user chooses **Hide My Email**, Apple supplies a private relay address such as `abc123@privaterelay.appleid.com`. That relay address is what Portal stores, so a user who signs in with Google/Magic Links using their real address and with Apple using a relay address is treated as **two separate end users**, each with their own wallet. Relay addresses are stable, so a user who returns via Apple with **Hide My Email** lands on the same end user and the same wallet each time. The mismatch only shows up when the same person also uses a different sign-in method. If you offer more than one method, decide up front how you want this to behave. Presenting Apple as the only option avoids the problem entirely, as does treating the two sign-ins as genuinely separate accounts. What does not work is assuming a person is one end user regardless of how they signed in. ## Next steps Add a TOTP second factor to every sign-in. Full reference for the OAuth endpoints. # Email magic links Source: https://docs.portalhq.io/resources/authentication/email-magic-links Verify a sending domain, build a magic link email template, and sign users in from a link sent to their inbox. A magic link signs a user in from an email. Your app asks Portal to email a link, the user clicks it, and they land back on your site with a single-use token your app exchanges for a Client Session Token. Magic links are sent from **your** domain, not from Portal, so two things have to be in place first: 1. A **sending domain**, verified with DNS records and enabled for the environment. 2. An **email template**, containing the sign-in link. Both are configured once per custodian and can then be used by any of your environments. ## Step 1: Add a sending domain Step 1: Click **Domains** in the left sidebar, under Authentication. Step 2: Click to add a domain and fill in: * **Domain**: the domain the emails are sent from, for example `auth.example.com`. * **From email**: the address the emails come from. It must be an address on the domain above, for example `hello@auth.example.com`. * **Environments**: which environments are allowed to send from this domain. Domains list showing each sending domain with its from email and assigned environments The from email has to be lowercase and on the domain you entered. A from email such as `Hello@auth.example.com` or `hello@other.example.com` is rejected. ## Step 2: Add the DNS records Saving the domain takes you to its detail page, which lists the DNS records to add at your DNS provider. These are the SPF and DKIM records that let mailbox providers confirm the email genuinely came from your domain. Domain detail page listing the SPF and DKIM DNS records to add, each with type, name, value, and status Add every record exactly as shown, then come back and click **Verify DNS records**. Verify DNS records confirmation modal explaining that verification fails if any record is missing or incorrect Verification checks all of the records at once and fails if any one of them is missing or does not match. DNS changes can take a while to propagate, so if verification fails immediately after you add the records, wait and check the domain again. ### Domain statuses | Status | Meaning | | -------------------- | -------------------------------------------------------------------- | | `not_started` | The records have not been checked yet. | | `pending` | Verification is in progress. | | `verified` | All records are correct. The domain can send. | | `partially_verified` | Some records are correct and others are not yet. | | `failed` | Verification failed. Re-check the records against your DNS provider. | | `partially_failed` | Some records failed. | A domain has to reach `verified` before magic links will send from it. ## Step 3: Create an email template Step 1: Click **Templates** in the left sidebar, under Authentication. Step 2: Create a template and give it a **name** and a **subject**. Templates list showing each template's name, subject, and copyable ID Step 3: Build the email body in the editor. Portal starts you off with a working default: a heading, a short explanation, a sign-in button, and a fallback link. Magic link template editor showing the visual editing surface with the sign-in button selected ### The magic link variable Whatever else you change, the template has to contain the magic link. The sign-in URL is injected at send time through the `MAGIC_LINK` variable, written with **three** braces on each side: ```html theme={null} Sign in ``` It has to be the `href` of a link. Portal checks for exactly that before saving, and blocks the save with a **Sign in button not configured** warning if it cannot find it. A template without it would send users an email they cannot sign in from, so there is no way to save around the check. Putting `{{{MAGIC_LINK}}}` in the body as visible text does not satisfy the check, and neither does two braces or a button whose `href` points anywhere else. It has to appear as `href="{{{MAGIC_LINK}}}"` on an anchor. Case does not matter when you type it. `{{{magic_link}}}` is normalized to `{{{MAGIC_LINK}}}` when you save, and the check runs after that normalization, so lowercase placeholders pass. ### The visual editor New templates open in the visual editor, prefilled with a working design: a heading, a short explanation, a sign-in button already wired to `{{{MAGIC_LINK}}}`, a fallback link with the raw URL for clients where the button does not render, and a closing line telling the recipient to ignore the email if they did not request it. You can edit text, adjust alignment and colours, insert images, and restyle the sign-in button. The button and the fallback link are variable-aware, so they keep pointing at the magic link as you restyle them and you do not need to touch the placeholder by hand. If you only need to change the wording and branding, stay here. Portal preserves the editor's state with the template, so reopening it later gives you the same document back rather than a re-imported approximation. ### The HTML editor For full control over the markup, switch the template to HTML editing. The code view is a split screen: an editable, syntax-highlighted HTML editor on the left, and a live preview of that HTML on the right, updating as you type. The preview is sandboxed, so scripts in the markup do not run. Use it when the visual editor cannot express what you need, for example bringing across an existing email design, hand-tuning table layouts for a specific mail client, or adding media queries. Switching to HTML editing is a one-way change. Portal asks you to confirm, and once you edit the raw HTML the template can no longer be edited in the visual editor. Your current design is carried over as HTML, and the template reopens locked to the code view from then on. When you save from the code view, the `{{{MAGIC_LINK}}}` requirement is enforced here exactly as it is in the visual editor, which is the failure worth watching for. It is easy to restructure the markup by hand and lose the `href` in the process, and the warning modal is explicit that the button needs an `href` of `{{{MAGIC_LINK}}}`. The template name cannot be changed after the template is created. The subject and body can be edited at any time, and edits go live for new sends as soon as you save. ### Copy the template ID Your app passes the template's ID when it requests a magic link. Copy it from the ID column in the Templates list. Store it in your app's configuration alongside your from email. This is the Portal template ID shown in the dashboard. It is not the ID used by Portal's underlying email delivery provider, and the two are not interchangeable. ## Step 4: Send a magic link With a `verified` domain and a template, your app can send. Four fields are required, plus one optional flag. ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/auth/magic-links \ --header 'x-portal-auth-environment-id: ' \ --header 'Content-Type: application/json' \ --data '{ "email": "user@example.com", "redirectUrl": "https://example.com/callback", "fromEmail": "hello@auth.example.com", "templateId": "", "isAccountAbstracted": false }' ``` ```json theme={null} { "data": { "sent": true }, "metadata": null } ``` | Field | Requirement | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `email` | The user's email address. Must be lowercase. | | `redirectUrl` | Must exactly match one of the environment's Redirect URLs. | | `fromEmail` | Must belong to a verified domain that is enabled for this environment. | | `templateId` | Must be one of your Portal templates. | | `isAccountAbstracted` | Optional. Whether the client Portal creates for this user uses gas sponsorship. Omit it to use your organization's default. See [Gas sponsorship](/resources/authentication/overview#gas-sponsorship). | `sent: true` confirms Portal accepted the request and handed the email off for delivery. It does not confirm the message reached the inbox. Magic link sends are rate limited per environment and email address. If your app lets users request a new link, expect a `429` when someone taps the button repeatedly, and show them a "check your inbox or try again shortly" message rather than an error. ## Step 5: Exchange the token The user receives the email and clicks through to your Redirect URL, which Portal appends a token to: ``` https://example.com/callback?token=&authMethod=EMAIL_MAGIC_LINK ``` Read `token` from the query string and post it back: ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/auth/magic-links/validations \ --header 'x-portal-auth-environment-id: ' \ --header 'Content-Type: application/json' \ --data '{ "token": "" }' ``` ```json theme={null} { "data": { "endUserId": "clx1enduser00000000000000", "clientId": "clx1client00000000000000", "clientSessionToken": "b7c1f0a2-3d4e-5f60-8a91-2b3c4d5e6f70", "isAccountAbstracted": false, "userJwt": null, "totpLink": null }, "metadata": null } ``` `clientId` and `isAccountAbstracted` describe the client Portal created or reused for this user. If the environment requires a second factor, `userJwt` is set instead and `clientId`, `clientSessionToken`, and `isAccountAbstracted` are all `null`. They arrive with the TOTP validation response once the user enters their code. See [Two-factor authentication](/resources/authentication/two-factor-authentication). The token is single-use. Some email clients and security scanners pre-fetch links in messages, which can consume a token before the user clicks it. Exchange the token from the page the user lands on rather than from anything that runs when the link is merely fetched, and handle a `401` by offering to send a new link. ## Next steps Add a TOTP second factor to every sign-in. Full reference for the magic link endpoints. # Enable authentication Source: https://docs.portalhq.io/resources/authentication/enable-authentication Turn Portal-managed end user authentication on for an environment and configure which sign-in methods it accepts. Authentication is configured per environment from the [Portal Admin Dashboard](https://app.portalhq.io). Your Development and Production environments have separate settings, separate credentials, and separate Auth Environment IDs, so you can enable a method in Development without affecting live users. ## Open the configuration page Step 1: Log in to the [Portal Admin Dashboard](https://app.portalhq.io) and select the environment you want to configure. Step 2: Click **Configure** in the left sidebar, under Authentication. Authentication Configure page showing the Enabled toggle and the General, TOTP, Email Magic Link, Google OAuth, and Apple OAuth sections Step 3: Turn on **Enabled**. Until you do, the rest of the page stays hidden and every Authentication API request for this environment is rejected with `401`. Enable toggle in Configure page ## Auth Environment ID The **Auth Environment ID** is the value your app sends in the `x-portal-auth-environment-id` header on every Authentication API request. Portal generates it for you and it does not change. Copy it from this section. Auth Environment ID section with the generated identifier and a copy button Each environment has its own Auth Environment ID. You will also need it when registering redirect URIs with Google and Apple. ## General **Auto-create wallet** records whether your app should create a wallet for a user as soon as they finish signing in. This is a boolean that is internally used in Portal SDKs to signal if a wallet should be created at the time an End User is created. **Redirect URLs** is the allow list of pages in your app that a sign-in is allowed to return a user to. Add every callback page you use, for example `https://example.com/callback`. Redirect URLs section listing two allowed callback URLs with add and remove controls Redirect URLs are matched by exact string comparison. Wildcards and path prefixes are not supported, and `https://example.com/callback` does not match `https://example.com/callback/` or `https://example.com/callback?next=/home`. Add each exact URL you intend to pass as `redirectUrl`. A request whose `redirectUrl` is not on this list is rejected with `401`. This is what stops someone who has your Auth Environment ID from redirecting your users and their sign-in tokens to a site they control. ## Choose sign-in methods Each method has its own section with its own toggle. Enabling a method adds it to the `allowedAuthMethods` array returned by `GET /auth/methods`. * **Email Magic Link.** No credentials to enter here, but before you can send anything you need a verified sending domain and an email template. See [Email magic links](/resources/authentication/email-magic-links). * **Google OAuth.** Requires a Client ID and Client secret from Google Cloud Console. See [Google OAuth](/resources/authentication/google-oauth). * **Apple OAuth.** Requires a Team ID, App ID, Service ID, Key ID, and private key from your Apple Developer account. See [Apple OAuth](/resources/authentication/apple-oauth). ### Provider credentials are write-only Portal encrypts provider secrets and never returns them. If you need to check whether the credentials are configured or not, you can check the tag on the labels of the form. You can still replace the value if needed. Google OAuth section with the enable toggle and write-only Client ID and Client secret fields showing configured badges ## Require two-factor authentication The **TOTP** section adds a second factor to every sign-in for this environment, whichever method the user chose. TOTP section with the Require TOTP toggle and the TOTP issuer field * **Require TOTP** turns the second factor on. * **TOTP issuer** is the name shown next to the code in the user's authenticator app. Use your product name. Turning this on changes the shape of your sign-in flow, so read [Two-factor authentication](/resources/authentication/two-factor-authentication) before enabling it in Production. ## Verify your configuration Call `GET /auth/methods` with your Auth Environment ID to confirm the environment is live and reporting the methods you expect: ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/auth/methods \ --header 'x-portal-auth-environment-id: ' ``` ```json theme={null} { "data": { "allowedAuthMethods": ["EMAIL_MAGIC_LINK", "GOOGLE"], "autoCreateWallet": true } } ``` A `401` here means the header does not match an environment, or **Enabled** is still off. ## Next steps Verify a sending domain and build your magic link email. Create a Google OAuth client and register Portal's redirect URI. Set up a Services ID and Sign in with Apple key. Review who has signed in and manage their two-factor enrollment. # End users Source: https://docs.portalhq.io/resources/authentication/end-users Review the users who have signed in through Portal-managed authentication and reset their two-factor enrollment. An **end user** is the record Portal keeps for someone who signs in through Portal-managed authentication. Portal creates one the first time an email address signs in to an environment, and reuses it on every later sign-in, whichever method they use. Each end user maps to exactly one Portal client, which is what owns their wallet. That mapping is why a returning user keeps their wallet instead of getting a new one. End users are keyed on email address within an environment. The same person signing in to your Development and Production environments is two separate end users with two separate wallets, which is expected. A person arriving with two different email addresses is also two end users, so decide how you want that to behave if you offer several sign-in methods. ## Browse end users Step 1: Click **End Users** in the left sidebar, under Authentication. Step 2: Select the environment whose users you want to see. End Users list showing email, end user ID, auth methods, client ID, and created at columns with a search box above The list shows: | Column | Description | | ---------------- | ------------------------------------------------------------------------------------------------------- | | **Email** | The address the user signed in with. Sortable. | | **End User ID** | Portal's identifier for the end user, matching the `endUserId` returned by the validation endpoints. | | **Auth Methods** | Every method this user has signed in with. A user who has used both Google and a magic link shows both. | | **Client ID** | The Portal client created for this user. | | **Created At** | When the end user first signed in. Sortable. | Search by email address or end user ID to find a specific user. ## Inspect an end user Click a row to open the detail view, which is organized into three cards. End user detail view with the End User, Wallet, and TOTP cards * **End User** shows the end user ID, when they were created, their email, and the auth methods they have used. * **Wallet** shows the client ID associated with the user. * **TOTP** shows whether they are enrolled in two-factor authentication, and lets you reset it. This is not shown if TOTP is not enabled. ## Reset two-factor enrollment If a user loses the device holding their authenticator app, reset their enrollment. Step 1: Open the user from **Authentication > End Users**. Step 2: In the **TOTP** card, click **Reset TOTP** and confirm. The card switches from **Enrolled** to **Not enrolled**. On their next sign-in Portal returns a fresh `totpLink`, so your app shows the QR code again and they set up a new authenticator. See [Two-factor authentication](/resources/authentication/two-factor-authentication). Resetting enrollment removes the second factor until the user sets it up again, so the next sign-in is protected by their email or OAuth account alone. Confirm you are talking to the real account owner before resetting. The reset option only appears for users who are currently enrolled. ## Next steps How the TOTP flow works from your app's side. Back to how Portal-managed authentication fits together. # Google OAuth Source: https://docs.portalhq.io/resources/authentication/google-oauth Create a Google OAuth client, register Portal's redirect URI, and let your users sign in with Google. Portal handles the OAuth exchange with Google and hands your app a token once the user has consented. ## Step 1: Create the OAuth client in Google Cloud Console 1. Open the [Google Cloud Console](https://console.cloud.google.com/) and select or create a project. 2. Go to **APIs & Services > OAuth consent screen** and configure it if you have not already. Portal requests the `openid`, `email`, and `profile` scopes, all of which are non-sensitive, so this does not require Google verification for internal or standard use. 3. Go to **APIs & Services > Credentials** and click **Create Credentials > OAuth client ID**. Google Cloud Console Credentials page with the Create Credentials menu open on OAuth client ID 4. For **Application type**, choose **Web application**. 5. Give it a name you will recognize, for example `Portal Authentication`. Create OAuth client ID form with Application type set to Web application and a name entered 6. Under **Authorized redirect URIs**, click **Add URI** and add the Portal callback URL for each of your environments: ``` https://api.portalhq.io/api/v3/auth/oauth/callback/ https://api.portalhq.io/api/v3/auth/oauth/callback/ ``` Authorized redirect URIs section with a Portal callback URL added for each environment 7. Click **Create**, then copy the **Client ID** and **Client secret**. You do not need to add anything under **Authorized JavaScript origins**. The OAuth exchange happens server to server between Portal and Google, not in your user's browser. ### The redirect URI Google needs The Google OAuth process should redirect to **Portal**, which verifies the sign-in with Google and then redirects the user on to your Redirect URL. The URI you register with Google is therefore a Portal URL: ``` https://api.portalhq.io/api/v3/auth/oauth/callback/ ``` Replace `` with the Auth Environment ID from **Authentication > Configure**. Because each environment has a different Auth Environment ID, **each environment needs its own entry** in Google's authorized redirect URIs. If you use Development and Production, register both. Google requires the redirect URI to match exactly. No trailing slash, and the Auth Environment ID has to be exact. A mismatch shows the user a `redirect_uri_mismatch` error from Google before they ever reach your app. ## Step 2: Enter the credentials in Portal **Information from Google (to enter in Portal):** * **Client ID**: the OAuth client identifier, ending in `.apps.googleusercontent.com` * **Client secret**: the OAuth client secret Step 1: In the dashboard, click **Configure** in the left sidebar, under Authentication, and select the environment. Step 2: In the **Google OAuth** section, turn on the toggle. Step 3: Paste the **Client ID** and **Client secret**, then save. Google OAuth section with the enable toggle and the Client ID and Client secret fields Both fields are write-only. After saving, each shows that a value is stored rather than the value itself. To rotate the secret, paste the new one and save again. To leave it unchanged, leave the field empty. If you use the same Google OAuth client for Development and Production, enter the same Client ID and secret in both environments. The credentials are stored per environment. ## Step 3: Sign a user in ### Get the authorize URL ```bash theme={null} curl --request GET \ --url 'https://api.portalhq.io/api/v3/auth/oauth/urls?redirectUrl=https%3A%2F%2Fexample.com%2Fcallback' \ --header 'x-portal-auth-environment-id: ' ``` ```json theme={null} { "data": { "google": "https://accounts.google.com/o/oauth2/v2/auth?client_id=..." }, "metadata": null } ``` The response contains a `google` key only if Google is enabled on the environment. `redirectUrl` must be URL-encoded in the query string and must exactly match one of your Redirect URLs. To create the user's client with gas sponsorship, add `isAccountAbstracted` to the query string: ```bash theme={null} curl --request GET \ --url 'https://api.portalhq.io/api/v3/auth/oauth/urls?redirectUrl=https%3A%2F%2Fexample.com%2Fcallback&isAccountAbstracted=true' \ --header 'x-portal-auth-environment-id: ' ``` Query parameters are strings, so send the literal `true` or `false`. Omit it to use your organization's default. See [Gas sponsorship](/resources/authentication/overview#gas-sponsorship). ### Send the user to Google Open `data.google` in a browser. The user picks their Google account and consents. Google returns them to Portal, Portal verifies the sign-in, and Portal redirects them to your Redirect URL: ``` https://example.com/callback?token=&login_type=GOOGLE ``` Magic links use an `authMethod` query parameter and OAuth uses `login_type`. If you support both, read whichever is present rather than assuming one name. ### Exchange the token ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/auth/oauth/tokens \ --header 'x-portal-auth-environment-id: ' \ --header 'Content-Type: application/json' \ --data '{ "token": "" }' ``` ```json theme={null} { "data": { "endUserId": "clx1enduser00000000000000", "clientId": "clx1client00000000000000", "clientSessionToken": "b7c1f0a2-3d4e-5f60-8a91-2b3c4d5e6f70", "isAccountAbstracted": false, "userJwt": null, "totpLink": null }, "metadata": null } ``` `clientId` and `isAccountAbstracted` describe the client Portal created or reused for this user. If the environment requires a second factor, `userJwt` is set instead and `clientId`, `clientSessionToken`, and `isAccountAbstracted` are all `null`. They arrive with the TOTP validation response once the user enters their code. See [Two-factor authentication](/resources/authentication/two-factor-authentication). ## Handle a failed sign-in If the user cancels, or anything in the exchange fails, Portal still redirects them to your Redirect URL, with an error instead of a token: ``` https://example.com/callback?error=oauth_failed ``` ## Google Drive backups are separate If you also use Google Drive as a wallet backup method, that is an independent authorization with its own scopes, requested by the SDK when the user backs up. Signing in with Google here does not grant Drive access, and it does not replace the setup described in the Google Drive backup guides. ## Next steps Add Sign in with Apple alongside Google. Full reference for the OAuth endpoints. # Overview Source: https://docs.portalhq.io/resources/authentication/overview Sign your end users in and get a Client Session Token without building your own auth backend. Portal can authenticate your end users for you. Instead of running your own login system and calling the Custodian API to mint a Client Session Token, you point your app at the Authentication API, the user signs in, and Portal hands your app a Client Session Token for that user's client. ## Sign-in methods | Method | Value in the API | What the user does | | ---------------- | ------------------ | ------------------------------------------------------ | | Email magic link | `EMAIL_MAGIC_LINK` | Receives an email from your domain and clicks the link | | Google | `GOOGLE` | Signs in with their Google account | | Apple | `APPLE` | Signs in with their Apple account | You choose which methods to enable, per environment. See [Enable authentication](/resources/authentication/enable-authentication). ## What you need before you start 1. Authentication turned on for the environment, with at least one method enabled. 2. Your environment's **Auth Environment ID**, from **Authentication > Configure** in the dashboard. Every request sends it in the `x-portal-auth-environment-id` header. 3. At least one **Redirect URL** on the environment's allow list. This is the page in your app that the sign-in returns the user to. ## How authentication works Every method follows the same three steps. 1. **Start the sign-in.** Your app asks Portal to send a magic link, or asks for a provider authorize URL and opens it. 2. **The user comes back to your app.** Portal redirects them to your Redirect URL with a single-use `token` query parameter appended. 3. **Exchange the token.** Your app posts that token to Portal and receives a Client Session Token. The token in step 2 is short-lived and single-use. If it expires or has already been exchanged, the user starts over. ### Identify with an Auth Environment ID The Authentication API is the one Portal API that does not take a Portal API Key or a Client Session Token. Instead, to communicate with the auth endpoints, you should use an `AuthEnvironmentId` (obtained from the dashboard): ```bash theme={null} curl --request GET \ --url https://api.portalhq.io/api/v3/auth/methods \ --header 'x-portal-auth-environment-id: ' ``` The Auth Environment ID identifies an environment, it does not authenticate a caller. Treat it as public, the way you would an OAuth client ID. This is why Portal checks every magic link request against your Redirect URL allow list, your verified sending domains, and your own templates, and rate limits magic link sends per email address. ## A complete sign-in Here is how a magic link sign-in plays out end to end. Google and Apple follow the same shape, differing only in how the sign-in starts: instead of sending an email, your app sends the user to the provider to consent. ```mermaid theme={null} sequenceDiagram autonumber actor User as End user participant App as Your app participant Portal participant Inbox as User's inbox App->>Portal: Which sign-in methods are enabled? Portal-->>App: Enabled methods User->>App: Enters their email address App->>Portal: Send a magic link to this address Portal->>Inbox: Sign-in email Portal-->>App: Link sent User->>Inbox: Opens the email User->>App: Follows the link back to your redirect URL App->>Portal: Exchange the sign-in token Portal-->>App: Client Session Token App-->>User: Signed in ``` Your app never sees the user's password, because there isn't one, and it never needs a server-side call to create the client. Portal creates the end user and their client during the exchange. Once your app holds the Client Session Token, it initializes a Portal SDK and carries on as it would with any other Portal client. If the environment requires two-factor authentication, one step is added between the exchange and the session: Portal returns a short-lived credential instead of the Client Session Token, and your app prompts for a code from the user's authenticator app first. See [Two-factor authentication](/resources/authentication/two-factor-authentication). For the requests and responses behind each step, see [Email magic links](/resources/authentication/email-magic-links) or the [API reference](/api-reference/auth-methods/get-the-enabled-auth-methods). ## Tokens in the flow | Token | What it is for | | ----------------------- | ---------------------------------------------------------------- | | `token` query parameter | Proves a sign-in completed. Exchanged for a session. Single use. | | `userJwt` | Authorizes the two-factor step, when required. Single use. | | `clientSessionToken` | Authenticates the client against the Portal APIs and SDKs. | The first two are short-lived. Once either is used or expires, the user has to sign in again. The Client Session Token behaves the same as any other Portal CST, and refreshes itself on every authenticated request, so an active user's session stays alive without extra calls. See [Authentication and API Keys](/resources/authentication-and-api-keys) for the details. One difference is worth calling out. Normally, when a CST finally expires, your backend mints a replacement with your Portal API Key. With Portal-managed authentication the end user just signs in again, and that sign-in issues a fresh Client Session Token. No Portal API Key and no backend call are involved. ## Wallet creation The **Auto-create wallet** setting is reported back to your app by `GET /auth/methods` as `autoCreateWallet`. It is a signal for your app and the Portal SDKs, not something the Portal API acts on. ## Gas sponsorship Gas sponsorship lets your organization pay network fees on behalf of your end users, using the policies and chains you configure. See [Account Abstraction](/resources/account-abstraction). The flag that turns it on is named `isAccountAbstracted`, but what it controls is whether the client Portal creates uses gas sponsorship. Portal creates the end user's client for you during the sign-in, so that choice is made when the sign-in **starts**, not when the token is exchanged. Pass `isAccountAbstracted` on the call that begins the flow: | Sign-in method | Where it goes | | ---------------- | ---------------------------------------------------------------------------------------------------- | | Email magic link | `isAccountAbstracted` boolean in the `POST /auth/magic-links` body | | Google or Apple | `isAccountAbstracted=true` or `isAccountAbstracted=false` in the `GET /auth/oauth/urls` query string | Query parameters are strings, so the OAuth call takes the literal `true` or `false`. Any other value is rejected with a `400`. Portal carries the value through the rest of the flow for you. Neither the token exchange nor the two-factor step takes the parameter again. | Value | What Portal does | | ------- | ------------------------------------------------------ | | Omitted | Uses your organization's default | | `false` | Creates a standard MPC client, with no gas sponsorship | | `true` | Creates a client that uses gas sponsorship | Sending `true` requires gas sponsorship to be enabled for your organization and configured for the environment. If either is missing, the call fails with a `400` rather than failing later at wallet creation: ```json theme={null} { "error": "Account Abstraction is not enabled, contact support to enable it" } ``` ```json theme={null} { "error": "Account Abstraction is not configured" } ``` The flag only takes effect when the client is created, on the user's first sign-in. A returning user keeps the client they already have, and sending a different value on a later sign-in does not change it. ### What comes back A completed sign-in returns the client Portal resolved for the user, so your app never has to guess: * `clientId`, the ID of the user's Portal client. * `isAccountAbstracted`, whether that client uses gas sponsorship. Both are returned alongside `clientSessionToken`, whether the sign-in finished at the token exchange or after a two-factor code. They are the client's actual values, which for a returning user may differ from what you sent. ## How this compares to other Portal credentials * **Portal API Key.** Your server-side key, used against the Custodian API to create clients and mint session tokens yourself. Unchanged, and still the right choice if you already run your own login. See [Authentication and API Keys](/resources/authentication-and-api-keys). * **Web OTP and `authUrl`.** The existing way to authenticate a Web SDK user, where your backend requests a one-time password for a client it already created. See [Web authentication methods](/sdks/web/guide/web-authentication-methods). * **Portal-managed authentication.** What this section covers. Portal identifies the user and creates the client, so you do not need a login system or a server-side call for first sign-in. ## Next steps Turn authentication on for an environment and configure its settings. Verify a sending domain, build an email template, and send magic links. Create a Google OAuth client and connect it to Portal. Set up Sign in with Apple and connect it to Portal. Full reference for every Authentication API endpoint. # Two-factor authentication Source: https://docs.portalhq.io/resources/authentication/two-factor-authentication Require a TOTP code from an authenticator app as a second factor on every end user sign-in. You can require a time-based one-time password (TOTP) as a second factor on top of whichever sign-in method the user chose. When it is on, completing a magic link or an OAuth sign-in is no longer enough on its own. The user also has to enter a six-digit code from an authenticator app such as Google Authenticator, 1Password, or Authy. The setting is per environment and applies to all sign-in methods at once. You cannot require a second factor for Google but not for magic links. ## Turn it on Step 1: In the dashboard, click **Configure** in the left sidebar, under Authentication, and select the environment. Step 2: In the **TOTP** section, turn on **Require TOTP**. Step 3: Set **TOTP issuer** to your product name. This is the label the user sees next to the code in their authenticator app, so make it recognizable. TOTP section showing the Require TOTP toggle and the TOTP issuer field Both settings are required. If **Require TOTP** is on but **TOTP issuer** is empty, Portal treats the environment as not requiring a second factor and completes sign-ins without one. Always set an issuer when you enable the toggle. ## What changes in your sign-in code With two-factor off, a validation response carries the Client Session Token and the sign-in is done. With it on, that same response comes back with `clientId`, `clientSessionToken`, and `isAccountAbstracted` set to `null`, and a `userJwt` instead. Only `endUserId` is present on both shapes. There are three possible shapes. Branch on `clientSessionToken`: ```json theme={null} { "data": { "endUserId": "clx1enduser00000000000000", "clientId": "clx1client00000000000000", "clientSessionToken": "b7c1f0a2-3d4e-5f60-8a91-2b3c4d5e6f70", "isAccountAbstracted": false, "userJwt": null, "totpLink": null }, "metadata": null } ``` The sign-in is complete. Use `clientSessionToken` to initialize a Portal SDK. ```json theme={null} { "data": { "endUserId": "clx1enduser00000000000000", "clientId": null, "clientSessionToken": null, "isAccountAbstracted": null, "userJwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.example.userjwt", "totpLink": null }, "metadata": null } ``` The user already has an authenticator set up. Prompt for their code. ```json theme={null} { "data": { "endUserId": "clx1enduser00000000000000", "clientId": null, "clientSessionToken": null, "isAccountAbstracted": null, "userJwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.example.userjwt", "totpLink": "otpauth://totp/Example%20App:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example%20App" }, "metadata": null } ``` The user has no authenticator yet. Render `totpLink` as a QR code for them to scan, then prompt for their code. `clientId` and `isAccountAbstracted` are `null` on both two-factor shapes because the sign-in has not completed yet. They come back from the TOTP validation below, alongside the Client Session Token. ### Enrollment `totpLink` is an `otpauth://` URI, the standard format authenticator apps expect. Render it as a QR code. `totpLink` is returned only until the user's first successful code entry. After that they are enrolled, and later sign-ins return `userJwt` with `totpLink` set to `null`. If a user abandons the sign-in before entering a code, they are still not enrolled and will get a `totpLink` again next time. ## Validate the code Send the `userJwt` as a Bearer token alongside your Auth Environment ID: ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/api/v3/auth/totps/validations \ --header 'x-portal-auth-environment-id: ' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "code": "123456" }' ``` ```json theme={null} { "data": { "clientId": "clx1client00000000000000", "clientSessionToken": "b7c1f0a2-3d4e-5f60-8a91-2b3c4d5e6f70", "isAccountAbstracted": false }, "metadata": null } ``` This is the only endpoint in the Authentication API that takes two credentials. The Auth Environment ID identifies the environment and the `userJwt` identifies the half-finished sign-in. The client is created, or reused, at the end of the sign-in, which is why `clientId` and `isAccountAbstracted` come back from this call rather than from the token exchange. If you opted into [gas sponsorship](/resources/authentication/overview#gas-sponsorship) when the sign-in started, that choice travels in the `userJwt`, so this request takes no `isAccountAbstracted` parameter of its own. ## Reset a user's authenticator If a user loses their device, reset their enrollment from the dashboard. Go to **Authentication > End Users**, open the user, and reset their TOTP enrollment. Their next sign-in returns a fresh `totpLink` so they can set up a new authenticator. See [End users](/resources/authentication/end-users). ## Next steps Review who has signed in and reset two-factor enrollment. Full reference for the TOTP validation endpoint. # Firebase Auth Backup Source: https://docs.portalhq.io/resources/backup-options/firebase-byo-auth Use your existing Firebase Authentication to back up and recover Portal wallets via a secure enclave. Allow customers to use their existing Firebase Authentication to authenticate into a secure enclave that holds the encryption key for the user. The Portal SDK leverages Firebase ID tokens to securely store and retrieve encryption keys from the secure enclave. ## How it works 1. Your app signs the user in with Firebase Auth (Email/Password, Google Sign-In, Phone, or any other Firebase Auth provider). 2. You configure the Portal SDK with a callback that returns a fresh Firebase ID token. 3. During **backup**, the SDK encrypts the client backup share and stores the encryption key in the secure enclave, authenticated by the Firebase ID token. 4. During **recovery**, the SDK retrieves the encryption key from the secure enclave using the same authentication, then decrypts the backup share to restore the wallet. ## Prerequisites 1. A Firebase project with Firebase Authentication enabled. 2. At least one sign-in provider configured in Firebase Auth (Email/Password, Google Sign-In, Apple Sign-In, etc.). 3. Your Firebase project registered with Portal (see below). ## Register your Firebase project with Portal You must register your Firebase project with Portal so that the secure enclave can validate your users' Firebase ID tokens. 1. Go to the [Portal Dashboard](https://app.portalhq.io). 2. Navigate to **Configuration > Webhooks**. 3. In the **Firebase Backup Authentication** section, enter your **Firebase Project ID** and save. You can also register the JWT provider programmatically via the Custodian API: ```bash theme={null} curl -X POST https://api.portalhq.io/api/v3/custodians/me/jwt-provider \ -H "Authorization: Bearer YOUR_CUSTODIAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "provider": "firebase", "projectId": "your-firebase-project-id" }' ``` ## Add Firebase to your app If you have not already set up Firebase in your iOS app, follow the [Firebase iOS setup guide](https://firebase.google.com/docs/ios/setup). At a minimum, you need to: 1. Add your iOS app in the [Firebase Console](https://console.firebase.google.com). 2. Download the `GoogleService-Info.plist` file and add it to your Xcode project. 3. Add the `FirebaseAuth` dependency via Swift Package Manager or CocoaPods. 4. Call `FirebaseApp.configure()` in your `AppDelegate` or app entry point. If you have not already set up Firebase in your Android app, follow the [Firebase Android setup guide](https://firebase.google.com/docs/android/setup). At a minimum, you need to: 1. Add your Android app in the [Firebase Console](https://console.firebase.google.com). 2. Download the `google-services.json` file and add it to your `app/` directory. 3. Add the Firebase dependencies to your `build.gradle`: ```groovy theme={null} dependencies { implementation platform("com.google.firebase:firebase-bom:33.1.0") implementation "com.google.firebase:firebase-auth-ktx" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3" } ``` 4. Apply the Google services plugin: ```groovy theme={null} // In your project-level build.gradle plugins { id "com.google.gms.google-services" version "4.4.2" apply false } // In your app-level build.gradle plugins { id "com.google.gms.google-services" } ``` If you have not already set up Firebase in your Flutter app, follow the [FlutterFire setup guide](https://firebase.google.com/docs/flutter/setup). At a minimum, you need to: 1. Install the FlutterFire CLI: `dart pub global activate flutterfire_cli` 2. Run `flutterfire configure` to connect your Flutter app to your Firebase project. 3. Add the `firebase_core` and `firebase_auth` packages to your `pubspec.yaml`: ```yaml theme={null} dependencies: firebase_core: ^3.0.0 firebase_auth: ^5.0.0 portal_flutter: ^X.X.X ``` 4. Initialize Firebase in your app entry point: ```dart theme={null} import 'package:firebase_core/firebase_core.dart'; import 'firebase_options.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ); runApp(MyApp()); } ``` If you have not already set up Firebase in your web app, follow the [Firebase Web setup guide](https://firebase.google.com/docs/web/setup). At a minimum, you need to: 1. Add a Web app in the [Firebase Console](https://console.firebase.google.com) and copy your Firebase config (`apiKey`, `authDomain`, `projectId`, etc.). 2. Install the Firebase JavaScript SDK: ```bash theme={null} npm install firebase ``` 3. Initialize Firebase in your app (for example in your root layout or entry module): ```typescript theme={null} import { initializeApp, getApps } from 'firebase/app' import { getAuth } from 'firebase/auth' const firebaseConfig = { apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, } const app = getApps().length ? getApps()[0]! : initializeApp(firebaseConfig) export const firebaseAuth = getAuth(app) ``` 4. Sign users in with any Firebase Auth provider your app supports (Email/Password, Google, etc.) before calling Portal backup or recovery with `BackupMethods.firebase`. ## Supported platforms Firebase Auth Backup is currently available on: * **iOS** - See the [iOS Back up a wallet guide](/sdks/ios/guide/back-up-a-wallet#firebase-auth-backup) * **Android** - See the [Android Back up a wallet guide](/sdks/android/guide/back-up-a-wallet#firebase-auth-backup) * **Flutter** - See the [Flutter Back up a wallet guide](/sdks/flutter/guide/back-up-a-wallet#firebase-auth-backup) * **React Native** - See the [React Native Back up a wallet guide](/sdks/react-native/guide/back-up-a-wallet#firebase-auth-backup) * **Web** - See the [Web Back up a wallet guide](/sdks/web/guide/back-up-a-wallet#firebase-auth-backup) # GDrive Source: https://docs.portalhq.io/resources/backup-options/gdrive This is to allow your app to have access to store files in your users google drive. ## Create a google app To enable Google Drive backups you will need to create a Google Project within your organization that will act as the OAuth consumer. * Create a new Google project [in the Google Cloud Console](https://console.cloud.google.com/) * On the [APIs & Services](https://console.cloud.google.com/apis) page click `ENABLE APIS AND SERVICES` * Search for `Google Drive API`, select it, and click `Enable` * Click on OAuth consent screen * For user type select internal. * Fill out the relevant information. * Our SDK requires these scopes ``` DRIVE_FILE: "https://www.googleapis.com/auth/drive.file" DRIVE_APPFOLDER: "https://www.googleapis.com/auth/drive.appdata" ``` * Click on Credentials in the left menu and select `+ Create Credentials` Before going to production, you'll need to verify your application and update your `User type` to `External` in order to allow external users to authenticate with Google. * Select `OAuth client ID` and choose `Android` from the `Application Type` list * Fill in the form as instructed, with `Name`, `Package name`, and `SHA-1` from your Keystore. * *Note that when testing you'll want to use your debug.keystore at /android/app/debug.keystore; However, you'll need to switch this value when pushing it to the store.* * *Find the Sha-1 fingerprint with this command from the root project folder:* `keytool -keystore ./app/debug.keystore -list -v` * Click on Credentials in the left menu, select `+ Create Credentials` again, and select `Oauth client ID` again. Then select `Web Application` as the Application type. * Input the `Name`. Then click create and copy the value from `Your Client ID`. * This value is what you will pass into the `PortalOptions` config when instantiating your `Portal` class. * **Important:** Add emails for internal testing until you've published your app for external use. * Select `OAuth client ID` and choose `iOS` from the `Application Type` list * Get your `Bundle ID` from the react-native / iOS project * Once you've successfully created your iOS client ID, download the .plist config and copy the `REVERSED_CLIENT_ID` to the URL Scheme section of your `info.plist` either in xcode or your IDE: ``` ... CFBundleURLTypes CFBundleTypeRole Editor CFBundleURLSchemes REVERSE_CLIENT_ID ... ``` * Rename the downloaded `.plist` file to `GoogleService-Info.plist` and save that to your `/ios` directory * Copy and paste the `Client ID` from the `GoogleService-Info.plist`into the `PortalOptions` config object when instantiating your `portal` class ### Useful Google Resources Should I set scopes in the OAuth Consent Screen? * Our backup method does not require any sensitive or restrictive scopes as defined by google; however, setting scopes for your application will limit the functionality to only what is required from the permission if the temporary auth token was to get compromised. It is [recommended by google ](https://developers.google.com/identity/protocols/oauth2/production-readiness/policy-compliance?authuser=1#sensitive-restricted-scope-verification)to register all scopes Should I get google approval? * You do not *need* to in order to publish your app because we don't require sensitive or restrictive scopes; however, if you would like brand verification you will need to get google permissions. Those times vary from a couple days to two weeks. [*Google docs*](https://developers.google.com/identity/protocols/oauth2/production-readiness/brand-verification?authuser=1) # iCloud Source: https://docs.portalhq.io/resources/backup-options/icloud This storage adapter allows end users to save backup MPC shares to their iCloud filesystem. ***Note**: This storage provider is only supported on iOS devices, so should not be used as your sole backup storage adapter unless you are explicitly releasing an iOS-only app.* ***Note:** you will need a paid developer account to use iCloud backup* ***Note:*** iCloud recovery is tied to the application's bundle identifier. This means that data can only be restored within the same app identified by that bundle ID. If you're migrating your app to a different technology (e.g., from iOS Native to React Native), the new version of the app must be released using the same bundle identifier as the original. Changing the bundle identifier will prevent iCloud backups from being restored in the new app version. ### Setting up iCloud Backups in your iOS app 1. Open your project in XCode. 2. Navigate to `Signing & Capabilities`. 3. Click the `+` Button right below it, scroll down and select `iCloud`. 4. Under iCloud Services select `Key-value storage`. # Passkey + Enclave Source: https://docs.portalhq.io/resources/backup-options/passkey-+-enclave Allow customers to create a native passkey on their device that is used to authenticate into a secure enclave that holds the encryption key for the user. Customers passkeys are backed up to the native cloud storage for their device. ### Implementation Requirements 1. Initialize passkey storage as a backup option in the Portal Config Object. 2. Add `portalhq.io` as a trusted credential domain in your app. 3. Share your app id with the Portal Team. Check out our quick start guides for implementing password storage in our sdks: iOS SDK - [#passkey-+-enclave-backup](../../sdks/ios/guide/back-up-a-wallet) Android SDK - [#passkey--enclave-backup](../../sdks/android/guide/back-up-a-wallet) React Native SDK - [#passkey--enclave](../../sdks/react-native/guide/back-up-a-wallet) Web SDK - [#passkey--enclave-backup](../../sdks/web/guide/back-up-a-wallet) # Password/PIN Source: https://docs.portalhq.io/resources/backup-options/password-pin Allow customers to create a password/pin. Customers can either remember the password or store it in a password storage manager. ### Implementation Requirements 1. Create a UI for password input. 2. Enforce password requirements. Customer can choose between password, PIN code, passcode, or any other text-based input. Check out our quick start guides for implementing password storage in our sdks: iOS SDK - [*password storage*](../../sdks/ios/guide/back-up-a-wallet) Android SDK - [*password storage*](../../sdks/android/guide/back-up-a-wallet) React Native SDK - [*password storage*](../../sdks/react-native/guide/back-up-a-wallet) Web SDK - [*password storage*](../../sdks/web/guide/back-up-a-wallet) # Chain support Source: https://docs.portalhq.io/resources/blockchain-support ### Primary support * **Monad** (`eip155:143`) * **Ethereum** (`eip155:1`) * **Solana** (`solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`) * **Stellar** (`stellar:pubnet`) * **Tron** (`tron:mainnet`) * **Bitcoin** (`bip122:000000000019d6689c085ae165831e93-p2wpkh`) * **Arbitrum** (`eip155:42161`) * **Avalanche** (`eip155:43114`) * **Base** (`eip155:8453`) * **Optimism** (`eip155:10`) * **Polygon** (`eip155:137`) * **Celo** (`eip155:42220`) ### Additional support * Arbitrum Blueberry (`eip155:88153591557`) * Arbitrum Nova Mainnet (`eip155:42170`) * Arbitrum Sepolia (`eip155:421614`) * Astar (`eip155:592`) * Astar zKyoto (`eip155:6038361`) * Astar zkEVM (`eip155:3776`) * Avalanche Fuji Testnet (`eip155:43113`) * Base Sepolia (`eip155:84532`) * Bera (`eip155:80084`) * Binance Smart Chain (`eip155:56`) * Binance Smart Chain Testnet (`eip155:97`) * Bitcoin Testnet (`bip122:000000000933ea01ad0ee984209779ba-p2wpkh`) * Blast Mainnet (`eip155:81457`) * Blast Sepolia Testnet (`eip155:168587773`) * BlastChain Mainnet (`eip155:238`) * Celo Alfajores Testnet (`eip155:44787`) * Chiliz (`eip155:88888`) * Chiliz Testnet (`eip155:88882`) * Cronos (`eip155:25`) * CrossFi Testnet (`eip155:4157`) * Cyber Mainnet (`eip155:7560`) * Cyber Testnet (`eip155:111557560`) * Degen Mainnet (`eip155:666666666`) * Ethereum Sepolia (`eip155:11155111`) * Fantom (`eip155:250`) * Fantom Testnet (`eip155:4002`) * Flow EVM (`eip155:747`) * Flow EVM Testnet (`eip155:545`) * Fraxtal (`eip155:252`) * Fraxtal Sepolia (`eip155:2522`) * Gnosis (`eip155:100`) * Gnosis Chaido (`eip155:10200`) * Holesky (`eip155:17000`) * Immutable zkEVM (`eip155:13371`) * LaChain (`eip155:274`) * LaChain Testnet (`eip155:418`) * Linea Mainnet (`eip155:59144`) * Linea Sepolia (`eip155:59141`) * LiteCoin (`bip122:12a765e31ffd4059bada1e25190f6e9`) * Mantle (`eip155:5000`) * Metis (`eip155:1088`) * Moonbase (`eip155:1287`) * Monad Testnet (`eip155:10143`) * OP Celestia Raspberry (`eip155:123420111`) * Optimism Sepolia (`eip155:11155420`) * Palm (`eip155:11297108109`) * Polygon Amoy (`eip155:80002`) * Polygon ZkEVM (`eip155:11011`) * Polygon ZkEVM Cardona (`eip155:2442`) * Polynomial (`eip155:8008`) * Polynomial Sepolia (`eip155:80008`) * Rootstock (`eip155:30`) * Rootstock Testnet (`eip155:31`) * Scroll Mainnet (`eip155:534352`) * Scroll Sepolia (`eip155:543351`) * Shape (`eip155:360`) * Shape Sepolia (`eip155:11011`) * Solana Devnet (`solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`) * Stellar Testnet (`stellar:testnet`) * Tron Nile Testnet (`tron:nile`) * Tron Shasta Testnet (`tron:shasta`) * Zeta (`eip155:7000`) * Zeta Testnet (`eip155:7001`) * Zora Mainnet (`eip155:7777777`) * Zora Sepolia (`eip155:999999999`) * opBNB (`eip155:204`) * opBNB Testnet (`eip155:204`) * zKatana (`eip155:1261120`) * zkSync Mainnet (`eip155:324`) * zkSync Sepolia (`eip155:300`) If you are interested in using a chain that is not listed, please reach out to our support team. ### Core Feature Support Features * **Signing**: Supports signing and broadcasting transactions to chain. * **Default RPC**: Supports Portal provided RPC configurations out-of-the-box. Otherwise RPC credentials will need to be provided. * **EIP-1193 Provider**: Supports the EIP-1193 standard in the SDK for integration with third-party libraries. * **Eject**: Supports ejection of a single private key to import into a non-MPC wallet application. | Chain Name | CAIP-2 Reference ID | Signing | Default RPC | EIP-1193 Provider | Eject | | ------------------- | --------------------------------------- | ------- | ----------- | ----------------- | ----- | | Arbitrum Nova | eip155:42170 | ✅ | ✅ | ✅ | ✅ | | Arbitrum One | eip155:42161 | ✅ | ✅ | ✅ | ✅ | | Astar | eip155:592 | ✅ | ✅ | ✅ | ✅ | | Astar zkEVM | eip155:3776 | ✅ | ❌ | ✅ | ✅ | | Avalanche | eip155:43114 | ✅ | ✅ | ✅ | ✅ | | Base | eip155:8453 | ✅ | ✅ | ✅ | ✅ | | Bera | eip155:80084 | ✅ | ✅ | ✅ | ✅ | | Binance Smart Chain | eip155:56 | ✅ | ✅ | ✅ | ✅ | | Bitcoin | bip122:000000000019d6689c085ae165831e93 | ✅ | ❌ | ❌ | ❌ | | Blast | eip155:81457 | ✅ | ✅ | ✅ | ✅ | | BlastChain | eip155:238 | ✅ | ❌ | ✅ | ✅ | | Celo | eip155:42220 | ✅ | ❌ | ✅ | ✅ | | Chiliz | eip155:88888 | ✅ | ❌ | ✅ | ✅ | | Cronos | eip155:25 | ✅ | ❌ | ✅ | ✅ | | Cyber | eip155:7560 | ✅ | ❌ | ✅ | ✅ | | Degen | eip155:666666666 | ✅ | ❌ | ✅ | ✅ | | Ethereum | eip155:1 | ✅ | ✅ | ✅ | ✅ | | Fantom | eip155:250 | ✅ | ✅ | ✅ | ✅ | | Flow EVM | eip155:747 | ✅ | ✅ | ✅ | ✅ | | Fraxtal | eip155:252 | ✅ | ✅ | ✅ | ✅ | | Gnosis | eip155:100 | ✅ | ✅ | ✅ | ✅ | | Immutable zkEVM | eip155:13371 | ✅ | ❌ | ✅ | ✅ | | LaChain | eip155:274 | ✅ | ❌ | ✅ | ✅ | | Linea | eip155:59144 | ✅ | ✅ | ✅ | ✅ | | Lisk | eip155:1135 | ✅ | ❌ | ✅ | ✅ | | Mantle | eip155:5000 | ✅ | ✅ | ✅ | ✅ | | Metis | eip155:1088 | ✅ | ✅ | ✅ | ✅ | | Monad | eip155:143 | ✅ | ✅ | ✅ | ✅ | | opBNB | eip155:204 | ✅ | ✅ | ✅ | ✅ | | Optimism | eip155:10 | ✅ | ✅ | ✅ | ✅ | | Palm | eip155:11297108109 | ✅ | ❌ | ✅ | ✅ | | Polygon | eip155:137 | ✅ | ✅ | ✅ | ✅ | | Polygon ZkEVM | eip155:11011 | ✅ | ✅ | ✅ | ✅ | | Polynomial | eip155:8008 | ✅ | ✅ | ✅ | ✅ | | Polynomial Sepolia | eip155:80008 | ✅ | ✅ | ✅ | ✅ | | Pulsechain | eip155:369 | ✅ | ❌ | ✅ | ✅ | | Ronin | eip155:2020 | ✅ | ❌ | ✅ | ✅ | | Rootstock | eip155:30 | ✅ | ✅ | ✅ | ✅ | | Scroll | eip155:534352 | ✅ | ✅ | ✅ | ✅ | | Shape | eip155:360 | ✅ | ✅ | ✅ | ✅ | | Solana | solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp | ✅ | ✅ | ❌ | ✅\*\* | | Stellar | stellar:pubnet | ✅ | ❌ | ❌ | ❌ | | Tron | tron:0x2b6653dc | ✅ | ❌ | ❌ | ❌ | | Zeta | eip155:7000 | ✅ | ✅ | ✅ | ✅ | | zkSync | eip155:324 | ✅ | ✅ | ✅ | ✅ | | Zora | eip155:7777777 | ✅ | ✅ | ✅ | ✅ | \*\* Solana wallets are ejected as scalar representations. # Chain ID formatting Source: https://docs.portalhq.io/resources/chain-id-formatting In the world of blockchain technology, it's common to encounter different formats for representing chain IDs. While the traditional EVM chain ID format, like `1` for Ethereum mainnet, is widely recognized, [the CAIP-2 format](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md) offers a more comprehensive way to denote chain IDs across multiple curves. **At Portal, we primarily use the CAIP-2 format for chain IDs for this reason.** This document will clarify the differences and provide examples for Ethereum, Solana, and other EVM chains. ### Traditional EVM Chain ID Format The traditional EVM chain ID format is a straightforward numerical representation used by Ethereum Virtual Machine (EVM) compatible chains. Here are some examples: * **Monad**: `143` * **Ethereum**: `1` * **Optimism**: `10` * **Polygon**: `137` * **Arbitrum**: `42161` ### CAIP-2 Chain ID Format The CAIP-2 ([Chain Agnostic Improvement Proposal 2](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md)) format provides a more structured way to represent chain IDs, including the namespace and reference, which helps in uniquely identifying different blockchain networks. The format is `namespace:reference`. #### Examples: * **Monad**: `eip155:143` * **Solana:** `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` * **Ethereum**: `eip155:1` * **Optimism**: `eip155:10` * **Polygon**: `eip155:137` * **Arbitrum**: `eip155:42161` * **Tron:** `tron:mainnet` * **Stellar:** `stellar:pubnet` * **Bitcoin:** `bip122:000000000019d6689c085ae165831e93-p2wpkh` The only exception to our chains adhering to the CAIP-2 standardized format is Bitcoin networks, which require special consideration due to the variety of address types they support. ### Conversion Table
Chain CAIP-2 Format
Monad Mainnet eip155:143
Monad Testnet eip155:10143
Solana Mainnet solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp
Solana Devnet solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1
Ethereum Mainnet eip155:1
Optimism eip155:10
Polygon eip155:137
Arbitrum eip155:42161
Base eip155:8453
Base Sepolia eip155:84532
Celo eip155:42220
Celo Alfajores Testnet eip155:44787
Stellar stellar:pubnet
Stellar Testnet stellar:testnet
Tron tron:mainnet
Tron Nile Testnet tron:nile
Tron Shasta Testnet tron:shasta
Bitcoin (p2wpkh) bip122:000000000019d6689c085ae165831e93-p2wpkh
Bitcoin Testnet (p2wpkh) bip122:000000000933ea01ad0ee984209779ba-p2wpkh
### Understanding the CAIP-2 Format This includes the namespace (e.g. `eip155` for Ethereum-based chains) followed by the chain-specific identifier. It is designed to be chain-agnostic and supports a wider variety of blockchain networks beyond just EVM-compatible ones. ### Why CAIP-2? The CAIP-2 format is essential for environments where multiple blockchain networks need to be identified and interacted with. It provides a standardized way to refer to chains, reducing ambiguity and enhancing interoperability across different platforms and applications. *** Understanding both the traditional EVM chain ID format and the CAIP-2 format is crucial for interacting with multi-curve environments. If you have any questions or need further clarification, please reach out to our support team! # Delegations Source: https://docs.portalhq.io/resources/delegations Learn how token delegations work in Portal and how to approve, revoke, and transfer tokens on behalf of delegated addresses. ## Overview Portal's Delegations feature allows you to manage token approvals and delegated transfers across EVM and Solana chains. With delegations, you can: * **Approve** other addresses to spend tokens on your behalf * **Revoke** existing delegations * **Check status** of active delegations * **Transfer tokens** as a delegate from another address ## What are Token Delegations? Token delegations enable one address (the owner) to authorize another address (the delegate) to transfer tokens on their behalf, up to a specified amount. **Delegations apply to ERC-20 tokens (EVM) and SPL Tokens (Solana) only. Native gas tokens such as ETH, MON, and SOL are not supported.** Native assets are moved via a transaction's `value` field and have no on-chain `approve` / `transferFrom` (or SPL delegate) semantics to hook into, so delegation is not a valid operation for them at the protocol level. If you need "delegated" spending of a native asset, wrap it first (e.g. ETH → WETH) and delegate the wrapped ERC-20, or use a purpose-built spend-limit module (e.g. an EIP-7702 delegated contract). ### EVM Chains (ERC-20) On EVM chains, delegations work through the ERC-20 `approve` function: 1. **Owner approves** a delegate address for a specific token amount 2. **Delegate can transfer** tokens using `transferFrom` up to the approved amount 3. **Owner can revoke** by setting approval to zero Calling any delegation endpoint with a native asset (e.g. `ETH` on Ethereum, `MON` on Monad, `SOL` on Solana) will return an error. Common use cases: * DEX trading (approve router to swap tokens) * Lending protocols (approve to deposit collateral) * NFT marketplaces (approve to list tokens) * Gasless transactions (approve relayer to pay gas) ### Solana (SPL Token) On Solana, delegations work through SPL Token delegate authorities: 1. **Owner delegates** authority to another address for specific token accounts 2. **Delegate can transfer** up to the delegated amount 3. **Owner can revoke** by removing the delegate authority ## Security Considerations Token delegations grant spending power over your tokens. Always: * Only approve trusted contracts and addresses * Set minimum required approval amounts * Revoke unused delegations * Regularly audit active delegations * Verify smart contract addresses before approval ### Best Practices 1. **Limit approval amounts**: Only approve the exact amount needed for a transaction 2. **Time-bound approvals**: Revoke delegations after use 3. **Monitor active delegations**: Use `getStatus()` to track approvals 4. **Validate addresses**: Always verify contract addresses from official sources 5. **Test on testnets**: Validate flows on testnet before mainnet deployment ## Supported Networks Delegations are available on all Portal-supported EVM and Solana chains: ### EVM Networks * Ethereum (`eip155:1`) * Ethereum Sepolia (`eip155:11155111`) * Polygon (`eip155:137`) * Base (`eip155:8453`) * Arbitrum (`eip155:42161`) * Optimism (`eip155:10`) * Monad (`eip155:143`) * Monad Testnet (`eip155:10143`) * And all other EVM chains supported by Portal ### Solana Networks * Solana Mainnet (`solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`) * Solana Devnet (`solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`) For a complete list of supported chains, see [Blockchain Support](/resources/blockchain-support). ## Key Operations ### Approve Delegation Grant permission for another address to spend tokens on your behalf. **Parameters:** * `chain`: CAIP-2 format chain ID (e.g., `eip155:1`, `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`) * `token`: ERC-20 contract address (EVM) or SPL Token mint address (Solana). Native assets like ETH, MON, and SOL are not supported. * `delegateAddress`: Address receiving approval * `amount`: Approval amount in raw token units (as string) **Returns:** * Unsigned transaction(s) ready for signing * Metadata about the approval operation ### Revoke Delegation Remove spending permission from a previously approved address. **Parameters:** * `chain`: CAIP-2 format chain ID * `token`: Token contract address or mint address * `delegateAddress`: Address to revoke approval from **Returns:** * Unsigned transaction(s) to revoke the approval * Metadata about the revocation ### Get Delegation Status Check current approval status and balances for a token. **Parameters:** * `chain`: CAIP-2 format chain ID * `token`: Token contract address or mint address * `delegateAddress`: Address to check approval for (required on EVM (eip155) chains; optional on Solana, where omitting it returns the account's current delegate) **Returns:** * Current token balance * List of active delegations with amounts * Delegation details per address ### Transfer From Transfer tokens as a delegate from another address to a destination. **Parameters:** * `chain`: CAIP-2 format chain ID * `token`: Token identifier (symbol or address) * `fromAddress`: Owner's address (where tokens are held) * `toAddress`: Destination address * `amount`: Amount to transfer (human-readable format) **Returns:** * Unsigned transaction(s) for the transfer * Metadata about the transfer operation ## Transaction Flow All delegation operations follow this flow: 1. **Call delegation method** (approve, revoke, transferFrom) 2. **Receive unsigned transaction(s)** with metadata 3. **Sign transaction** using Portal's signing methods 4. **Broadcast transaction** to the network 5. **Wait for confirmation** on-chain Portal's delegation methods return **unsigned transactions** that you must sign and broadcast. They do not automatically submit transactions to the blockchain. ## Chain-Specific Details ### EVM (Ethereum, Polygon, etc.) * Uses standard ERC-20 `approve()` and `transferFrom()` methods * Returns `transactions` array with unsigned transaction objects * Each transaction includes: `from`, `to`, `data`, `value` * Approval amounts are in wei/smallest token unit ### Solana * Uses SPL Token delegate instructions * Returns `encodedTransactions` array with base64-encoded transactions * Delegations are per token account, not per mint * Must specify exact token account addresses ## Next Steps Ready to implement delegations in your app? Choose your platform: * [Manage Delegations with the Enclave MPC API](/apis/enclave-mpc/guide/delegations) * [Manage Delegations with iOS](/sdks/ios/guide/delegations) * [Manage Delegations with Android](/sdks/android/guide/delegations) * [Manage Delegations with React Native](/sdks/react-native/guide/delegations) * [Manage Delegations with Web](/sdks/web/guide/delegations) ## Additional Resources * [Portal API Methods](/sdks/react-native/guide/portal-api-methods) - Learn about Portal's API architecture * [Chain ID Formatting](/resources/chain-id-formatting) - Understanding CAIP-2 format * [Security Best Practices](/resources/security) - Secure your Portal integration * [Error Codes](/resources/error-codes) - Complete error reference * [Client Delegations API](../apis/client/reference#get-delegation-status-for-an-address) - Check delegation status for an address using the Client API * [Custodian Delegations API](../apis/custodian/reference#get-clients-delegation-status-for-an-address) - Check delegation status for a client using the Custodian API # Eject Source: https://docs.portalhq.io/resources/eject Eject allows a user to construct a private key from their MPC shares so that they may import it into a wallet like MetaMask. ### How does it work? In order to be able to recover the private key using your MPC shares, they simply have to be "added" up. Now thats a simplification of the underlying cryptographic operation, but the takeaway here is that you can take two corresponding shares, combine them, and get the private key which is can be imported into any self-custodial wallet like Metamask. The resulting key material is an object with two private keys: a private key for `SECP256K1` - the curve used by Ethereum, Bitcoin, and [most other chains](http://ethanfast.com/top-crypto.html) - and `ED25519` - the curve used by Solana, Stellar, and [a few others](http://ethanfast.com/top-crypto.html). ```json theme={null} { "SECP256K1": "ebb895a7aba4dc1f9ae8b2ecd6989edc4379dc2bbf7bbf67ba073279bc54b30c", // hex encoded "ED25519": "7QoeLVEmauRJgEhLXEar3Et8VZVfuYEHYLw35FDZiFau" // base58 encoded } ``` The private key calculation operation happens on client side, and does not involve Portal's servers. After ejecting a user private key from their key shares, Portal is no longer responsible for the security of the funds in that wallet as we can no longer guarantee split custody. Reach out to us if you have any questions. ### When would I want to use Eject? Eject allows you to provide the option for your users to switch from a two share MPC architecture to a single private key while maintaining their assets. Put another way, it allows users to "export" their MPC wallet into a wallet provider like Metamask. This is a sensitive operation. We strongly recommend to guard this operation by multiple factors of authentication as it removes the security benefits of split custody and can leave a user more susceptible to phishing attacks. ### What happens to the shares stored on Portal after the user runs Eject? Can the user still use Portal after running Eject? After a user ejects their wallet Portal **still supports all MPC operations.** We update our database to record that the wallet has been ejected, but we do not suspend operation of the existing MPC shares or backups. While this is the default functionality, let us know if you have a use case with different requirements. ### What do I do with the ejected private keys? The `SECP256K1` private key can be imported into most third-party wallet providers via an "Import" function. Metamask, for example allows you to import a private key and manage all of your assets for the wallet. The `ED25519` private key is not currently directly compatible with third-party wallet providers. The exported private key is valid and can be used to generate signatures. However, a nuance with any distributed key generation (MPC) for `ED25519` prevents direct compatibility with third-party wallet providers. While we work with global standards groups to improve support for `ED25519` we have built an entirely client-side, open-source tool that you can fork and modify or simply point to our hosted version at [scalarwallet.org](https://scalarwallet.org). # Error Codes Source: https://docs.portalhq.io/resources/error-codes This guide documents the error identifiers returned by Portal's MPC (Multi-Party Computation) operations. This guide documents the error identifiers returned by Portal's MPC (Multi-Party Computation) operations. These errors follow a string-based identifier system that provides clear, meaningful error categorization. ### Error structure MPC operations return errors in a standardized JSON format: ```json theme={null} { "error": { "id": "RPC_OP_FAILED", "message": "Failed to send transaction: insufficient funds for gas * price + value: balance 1109136374275907, tx cost 2726006969664281, overshot 1616870595388374", "code": 123 // Deprecated - do not use! } } ``` #### Error fields * **`id`** (string): A stable, uppercase identifier. Use this field to identify and handle specific errors programmatically. * **`message`** (string): Human-readable details about the error. May contain dynamic information about the specific failure. This field is usually present but may occasionally be missing. * **`code`** (number): **DEPRECATED** - Legacy numeric error code maintained for backward compatibility. Will be removed in future versions. > **Note**: When using Portal SDK, the raw response structure may not be directly exposed to your application, but you can still access the `id` and `message` fields through the SDK's error handling mechanisms. ### Error identifier format Error identifiers follow a consistent naming pattern: ``` MODULE_[CONTEXT_]ERROR ``` * **MODULE**: The system component where the error originated (e.g., `AUTH`, `RPC`, `WS`) * **CONTEXT**: Optional qualifier providing additional context * **ERROR**: The specific error condition Examples: * `AUTH_FAILED` - Authentication failure * `MSG_BUILD_FAILED` - Failed to build the message * `DKG_FAILED` - Distributed Key Generation failed ### Example of errors in different categories #### General errors These errors can occur across various MPC operations: | Error ID | Description | | ----------------------- | ---------------------------------------------------------------------- | | `ERROR` | Generic error when a more specific error type isn't available | | `INTERNAL_ERROR` | Internal server error - typically indicates an unexpected system issue | | `BAD_REQUEST` | Invalid request format or parameters | | `NOT_FOUND` | Requested resource not found | | `STATE_INVALID` | Operation attempted in an invalid state | | `OPERATION_IN_PROGRESS` | Another operation is already in progress | | `WEBHOOK_FAILED` | Webhook verification or execution failed | #### Third-party integrations Errors returned by Portal's proxied integration endpoints (`/clients/me/integrations/*`). Unlike the MPC errors above, these are returned at the top level of the JSON body (`{ "error": "...", "id": "...", "details": { ... } }`). | Error ID | HTTP | Description | | ----------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `INTEGRATION_RESPONSE_SCHEMA_DRIFT` | 500 | The third-party provider returned a response that no longer matches the schema Portal documents for that endpoint (renamed field, changed type, missing required field). Portal is alerted automatically and fixes the mapping server-side. `details.integration`, `details.endpoint` and `details.issues[]` identify what drifted. | Handle `INTEGRATION_RESPONSE_SCHEMA_DRIFT` like a temporary outage of that one provider feature: show a graceful error, do not retry in a tight loop, and never treat a partial or missing field as a zero balance. #### Authentication & access control Errors related to authentication, authorization and security: | Error ID | Description | | ------------------------------ | ----------------------------------------------------------------------------------------------- | | `AUTH_FAILED` | Authentication failed - invalid credentials or token | | `ADDRESS_BLOCKED` | The address is blocked from performing this operation | | `POLICY_AMOUNT_LIMIT_EXCEEDED` | A [policy](/resources/policies) blocked the transaction for exceeding a configured amount limit | #### Transaction & message building Errors that occur when constructing, signing, and sending transactions and messages: | Error ID | Description | | -------------------------- | --------------------------------------------------------------------- | | `TX_BUILD_FAILED` | Failed to build the transaction | | `MSG_BUILD_FAILED` | Failed to build the message | | `METHOD_UNSUPPORTED` | The requested method is not supported | | `RPC_OP_FAILED` | RPC operation failed (often includes details like insufficient funds) | | `MPC_PROTOCOL_UNSUPPORTED` | The requested MPC protocol is not supported | #### WebSocket communication Network and WebSocket-specific errors: | Error ID | Description | | ------------------ | -------------------------------------------- | | `WS_MSG_MALFORMED` | WebSocket message format is invalid | | `WS_NETWORK_ERROR` | Network error during WebSocket communication | | `WS_CLOSED` | WebSocket connection was unexpectedly closed | | `WS_READ_FAILED` | Failed to read from WebSocket | | `WS_WRITE_FAILED` | Failed to write to WebSocket | #### MPC protocol operations Errors specific to Multi-Party Computation operations: | Error ID | Description | | ---------------------- | ------------------------------------ | | `DKG_FAILED` | Distributed Key Generation failed | | `DKG_REFRESH_FAILED` | DKG refresh operation failed | | `DKG_REFRESH_MISMATCH` | DKG refresh data mismatch detected | | `SIGN_FAILED` | Signature generation failed | | `SIGN_SHARE_MISMATCH` | Share mismatch detected when signing | | `ENCRYPT_FAILED` | Encryption operation failed | | `DECRYPT_FAILED` | Decryption operation failed | #### Key & share management Errors related to cryptographic keys and share operations: | Error ID | Description | | -------------------------------- | ------------------------------------- | | `SAVE_SIGNING_SHARE_FAILED` | Failed to save signing share | | `SAVE_BACKUP_SHARE_FAILED` | Failed to save backup share | | `PUBLIC_KEY_BUILD_FAILED` | Failed to build public key | | `PUBLIC_KEY_VERIFICATION_FAILED` | Public key verification failed | | `ADDRESS_CALC_FAILED` | Wallet address calculation failed | | `PRIVATE_KEY_RECOVERY_FAILED` | Private key recovery operation failed | | `SHARE_PARSING_FAILED` | Failed to parse provided share | | `METADATA_INVALID` | Invalid metadata provided | ### Error handling guidelines #### 1. Use error IDs for logic Always use the `id` field when implementing error handling logic. Error IDs are stable and designed for programmatic use. #### 2. Optional human-readable messages Use the `message` field to get details about the error. Depending on your use case, the message can be displayed to users or included as additional context in application error logs. Avoid parsing the message content programmatically, as the wording may change over time. #### 3. Avoid using numeric codes Do not use the deprecated `code` field. It exists only for backward compatibility and will be removed in future versions. #### 4. Handle missing messages While rare, the `message` field may occasionally be missing. Always provide a fallback when displaying errors to users. ### Common error resolution When encountering persistent errors that cannot be resolved through standard error handling: 1. **Check Portal Status**: Visit the [Portal status](https://portal.instatus.com/) page to check for any ongoing service disruptions or maintenance. 2. **Verify Input Data**: Ensure all input parameters are correctly formatted and valid: * Cipher text hasn't been modified or corrupted * Addresses are properly formatted * Transaction parameters are within acceptable ranges 3. **Contact Support**: If the issue persists, contact Portal support with: * The error ID * The complete error message * Steps to reproduce the issue * Any relevant request/response data (excluding sensitive information) ### Migration Guide If you're migrating from the old numeric error system to the new string-based identifiers: #### Old system (numeric codes) Previously, errors were identified by numeric codes in ranges: * 1xx: MPC Errors * 2xx: Network Errors * 3xx: General Errors * 4xx: Encryption Errors * 5xx: Portal Connect Errors #### New system (string IDs) Now, use specific string identifiers: ```javascript theme={null} // Old system - checking specific numeric codes if (error.code === 101) { // Handle DKG failure } if (error.code === 201) { // Handle network error } // New system - checking specific string IDs if (error.id === 'DKG_FAILED') { // Handle DKG failure } if (error.id === 'WS_NETWORK_ERROR' || ) { // Handle network error } ``` The string-based system provides more meaningful error identification without needing to remember numeric ranges or codes. ### Additional resources * [Portal Status Page](https://status.portalhq.io) # Upgrading to EIP-7702 Source: https://docs.portalhq.io/resources/evmAccountType Learn how to upgrade wallets to EIP-7702. ## Overview Portal's EVM Account Type feature enables you to upgrade your Portal wallets to EIP-7702, providing enhanced functionality and compatibility with smart contract features. With this module, you can: * **Check wallet status** to determine the current account type * **Upgrade to EIP-7702** to enable ejecting the Portal wallet's EOA private key ## What is EIP-7702? EIP-7702 is an Ethereum Improvement Proposal that allows Externally Owned Accounts (EOAs) to behave like smart contract accounts. This upgrade enables: * **Ejecting the EOA private key** from the Portal wallet * **Enabling smart contract capabilities** for the Portal wallet * **Saves gas fee** by not needing to deploy a smart contract EIP-7702 is not yet supported on all chains. Please check with the Portal team to see if your chain is supported. ### How the Upgrade Works When you upgrade a Portal wallet to EIP-7702: 1. **Status check** confirms the wallet is an `EIP_155_EOA` 2. **Authorization** is built and signed by the EOA 3. **Upgrade transaction** is submitted on-chain by your [upgrade sponsor configured in the Portal Dashboard](https://app.portalhq.io/upgrade-sponsorship) 4. **Wallet transforms** to `EIP_7702_EOA` account type Only wallets with an `EIP_155_EOA` account type can be upgraded to EIP-7702. Smart contract wallets and already upgraded wallets cannot perform this operation. ## Prerequisites Before upgrading a wallet to EIP-7702, ensure: * The wallet has `EIP_155_EOA` account type * The target chain supports EIP-7702 transactions * You fund the [Upgrade Sponsor address with native tokens for the target chain in the Portal Dashboard](https://app.portalhq.io/upgrade-sponsorship) ## Key Operations ### Get Wallet Status Check the current account type of your Portal wallet. **Parameters:** * `chain`: CAIP-2 format chain ID (e.g., `eip155:10143`) **Returns:** * `status`: Current account type (`EIP_155_EOA`, `SMART_CONTRACT`, or `EIP_7702_EOA`) * `eoaAddress`: The EOA address of your Portal wallet * `smartContractAddress`: The smart contract address of your Portal wallet (if account abstraction is enabled) * `chainId`: The chain identifier ### Upgrade to EIP-7702 Upgrade an EOA wallet to EIP-7702 to enable smart contract capabilities and eject the EOA private key. **Parameters:** * `chain`: CAIP-2 format chain ID **Returns:** * `txHash`: Transaction hash (if upgrade was performed) The `upgradeTo7702()` method handles the entire upgrade process automatically, including building the authorization, signing, and submitting the transaction. ## Transaction Flow The upgrade process follows these steps: 1. **Check status** using `getStatus()` to verify wallet is `EIP_155_EOA` 2. **Fund [Upgrade Sponsor address](https://app.portalhq.io/upgrade-sponsorship)** with native tokens for the target chain 3. **Call `upgradeTo7702()`** to perform the upgrade 4. **Wait for confirmation** on-chain 5. **Verify upgrade** by checking status again and ensuring the wallet is `EIP_7702_EOA` The upgrade is a one-way operation. Once the wallet is upgraded to EIP-7702, it cannot be downgraded. After upgrading to EIP-7702, you must use the EOA address of your Portal wallet to interact with the chain from `getStatus()`. ## Supported Networks The EVM Account Type feature is currently available on: * **Monad Testnet** (`eip155:10143`) * Additional EIP-7702 compatible chains (check with Portal team) ## Security Considerations Upgrading to EIP-7702 modifies how your wallet operates on-chain. Always: * Verify you're on the correct network before upgrading * Ensure the [Upgrade Sponsor address](https://app.portalhq.io/upgrade-sponsorship) has sufficient funds for gas * Test on testnet before mainnet deployment * Understand that the upgrade is a one-way operation per wallet ### Best Practices 1. **Check status first**: Always verify wallet type before attempting upgrade 2. **Fund adequately**: Ensure [Upgrade Sponsor address](https://app.portalhq.io/upgrade-sponsorship) has enough native tokens for gas fees 3. **Monitor transactions**: Track the transaction hash for confirmation 4. **Test thoroughly**: Validate on testnet before production use ## Next Steps Ready to implement EIP-7702 upgrades in your app? Choose your platform: * [iOS](/sdks/ios/guide/evm-account-type) * [Android](/sdks/android/guide/evm-account-type) * [React Native](/sdks/react-native/guide/evm-account-type) * [Web](/sdks/web/guide/evm-account-type) # Fee collectors Source: https://docs.portalhq.io/resources/fee-collectors Collect your own fee on Solana transfers your clients send through Portal. Fee collectors let you charge a fee on transfers your clients send with the [Build a transaction](/apis/client/reference) flow on Solana. You configure a destination address and a rate per environment in the Portal dashboard, and Portal appends the fee to each transfer it builds. The address and rate live in your dashboard configuration, never in the request, so a compromised client cannot redirect or change the fee. Fees are paid in whatever token is being sent. If your client sends USDC, the fee arrives as USDC at your fee address. ## Supported chains and tokens Fee collectors currently apply to Solana and Solana Devnet, for: * Native SOL transfers * SPL token transfers * Token-2022 token transfers Token-2022 mints that use a transfer hook are not supported yet: building a transfer for one returns a `400`, since the hook's extra accounts would make the transaction fail on-chain. Fees apply to transactions built by `POST /api/v3/clients/me/chains/{chain}/assets/send/build-transaction` and its custodian equivalent. Transactions your app builds itself and only signs through Portal do not go through fee collection. The build also checks that the sender's balance covers the amount plus all fees, and returns a `400` if it does not, rather than returning a transaction that would fail when broadcast. ## Setting up a fee collector In the dashboard, go to **Fee Collectors** under Configuration, then click **Add fee collector**. Choose a chain, the address that receives the fee, and a fee type: * **Percentage of the amount sent** charges a percentage of every transfer, on top of the amount sent. Rates go up to 100% in steps of 0.01%. * **Recover the network fee** charges what the transfer costs the network: the signature fee plus rent for any token accounts the transfer creates. You set a percentage of that cost, up to 200%, so 100% recovers exactly cost. The cost is converted into the token being sent using current market prices. If no fresh price is available for either asset, the fee is skipped for that transfer rather than charged on a stale quote. New collectors start disabled so you can verify the address before any transfer pays it. A collector's chain and address cannot be changed after creation. To move a fee to a new address, add a new collector and delete the old one. You can enable up to five collectors per environment and chain, with at most one collector per address on a given chain. Each enabled collector adds its own fee transfer to the transaction, and every fee is computed against the full send amount, so the order of collectors does not change what any of them receives. ## How fees appear in transactions The fee rides in the same transaction as the transfer, as an additional transfer instruction after the main one. For SPL and Token-2022 sends, the fee is paid to your fee address's associated token account for that mint. The first time a collector is paid in a given token, the transaction also creates that token account, and the fee payer (the gas sponsor when one is enabled, otherwise the sender) covers its rent. The build response reports what was charged in the Solana metadata: ```json theme={null} { "metadata": { "amount": "0.05", "rawAmount": "50000", "fees": [ { "address": "HUT3JXAZWq8XtPuyv8bbx5zduvvLyovtNfzGNv6spwqD", "amount": "162799", "collectorId": "cmtivu9yt00yetu49sqgnnozl", "feeType": "GAS_COST", "feeValue": 5000, "name": "Transfer Fee" } ], "totalFeeRawAmount": "162799" } } ``` `amount` in each fee entry and `totalFeeRawAmount` are in the sent token's base units. `feeValue` is the configured rate in basis points. ## When a fee is skipped A fee problem never fails a transfer. Portal drops an individual collector's fee, and the transfer goes through without it, when: * The fee rounds down to zero base units, so dust transfers do not pay one base unit to every collector. * A "Recover the network fee" collector has no fresh market price to convert the cost with. Portal skips the fee rather than charging on a stale quote. * A native SOL fee would leave the collector's account below Solana's rent-exempt minimum (about 0.00089 SOL). Fund the fee address with a small SOL balance once and every fee after that is collected regardless of size. SPL fees are unaffected. * The collector's address is the transfer's sender, which would pay the fee to itself. ## Letting clients skip a fee Each collector has a **Let clients skip this fee** toggle. When it is off, the fee is applied to every transfer Portal builds and the request cannot bypass it. When it is on, a client request can pass `skipFeeCollection: true` to build the transfer without that collector's fee: ```bash theme={null} curl -X POST "https://api.portalhq.io/api/v3/clients/me/chains/solana/assets/send/build-transaction" \ -H "Authorization: Bearer {clientToken}" \ -H "Content-Type: application/json" \ -d '{"to": "8APEEA4SHrfGteABQcUmH2yEHy7nEe8DqgvmKGAStHR", "token": "USDC", "amount": "0.05", "skipFeeCollection": true}' ``` `skipFeeCollection` only skips collectors with the toggle on. Collectors with the toggle off are always charged. Keep the toggle off unless your app has a real reason to let end clients opt out, since anything a legitimate client request can do, a compromised one can too. # Security firewall Source: https://docs.portalhq.io/resources/firewall In this page, we'll go through setting up policies on your security firewall to help protect your users/wallets from scams and threats. The security firewall is accessible through the Portal web app at [https://app.portalhq.io/](https://app.portalhq.io/login). Step 1: Click on "Firewall" in the left sidebar, under Configuration Step 2: Click on Block address on the top right and enter either a digital address or smart contract address with your reason.; Once that's completed the address will be set in a block state and available to be updated for the future. ### Dynamic scam and exploit protection Portal automatically blocks addresses, smart contracts and dApps associated with suspicious activity based on open source blocklists and updates the firewall with new information on a daily basis. Additionally, Portal reviews transactions in process, checking both the *to* and *data* fields of a transaction. If either field contains an address that has been blocked, the transaction is rejected. This protects users from sending a transaction to an otherwise safe address if that address is associated with any blocked addresses or smart contracts directly (determined by the *to* field) or indirectly (determined by the *data* field). For more detail information regarding the security firewall take a look at our blog post [here](https://www.portalhq.io/post/introducing-the-portal-wallet-firewall). # Glossary Source: https://docs.portalhq.io/resources/glossary A breakdown of all the relevant concepts and terms as it pertains to Portal | Term | Definition | How this relates to Portal | | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Multi-Party Computation (MPC) | A field of computing that aims to solve problems when computing a result amongst multiple computers (parties). A subset of MPC centers around performing cryptographic functions across multiple parties, where each has a single key share instead of one party having a single private key. | Portal is an MPC wallet company that provides Wallets-as-a-Service to our customers. | | Distributed Key Generation (DKG) | The MPC process that creates key shares on separate devices. | Portal uses DKG to power the Generate function. | | 2:2 (Two-for-Two) Key Configuration | This describes the MPC key configuration that Portal uses. It means that we create 2 key shares during DKG and require both of the 2 key shares to perform a signature or reshare. Specifically, it means there are 2 total keys and the threshold is 2. | Portal uses MPC in a 2:2 key configuration where an end user holds one key and Portal holds the other. | | Reshare | The MPC process that generates a new set of valid key shares, from previous key shares. The new key shares are still able to sign for the existing public key. | Portal uses reshare to power the Backup function. | | Share Mismatch | In a 2:2 key configuration, two linked keys are needed to perform a sign or reshare. If the two parties present keys that are not linked (i.e. from different DKGs or different reshare runs) than an MPC operation will fail because the key shares are mismatched (not the correctly linked shares). | Portal will throw errors indicating a Share Mismatch if an MPC operation has incorrect key shares. This can often occur if the client is using the wrong key share during the Backup function. (if the signing key is presented to Backup it will be mismatched with the Backup share presented on the server side) | | Elliptic Curve Digital Signature Algorithm (ECDSA) | ECDSA for short is the process of generating a key using a mathematical equation for an elliptic curve, y^2 = x^3 + ax + b.The equation generates a value on the curve which is multiplied by another value to create a new point on the curve. These points on the curve represent the public and private keys needed to authenticate a user | Portal uses ECDSA to create public and private keys for a client to then sign transactions on chain. Portal first goes through the MPC process to match and validate that the client and Portal key shares are linked and uses those 2 key shares to generate a public and private key needed to sign transaction onto chain. | | Threshold | An added MPC condition that forces a specified number of key shares to be accessible to validate the authenticity and proceed with the signing. | Portal uses a threshold of 2 when signing, meaning that both the portal key share and the client key share are required for signing. | | Key Share | A portion of the complete cryptographic key. MPC creates multiple key shares, depending on the key configuration chosen when. The aggregate of all key shares is the intended key needed for signing, anything less will be incomplete | Portal generates 2 key shares from our SDK’s that are then saved on the Client as well as with Portal. | # Going to Production Source: https://docs.portalhq.io/resources/going-to-production This guide will walk you through the process of taking your application to Production using Portal. ### Prerequisites Before we get started, please ensure you have the following: * An active account with Portal. * Access to the Portal web app. * Access to the Portal SDK you're using. * Wallets that have been tested in your "Development" environment and have successfully been generated, backed up, recovered, and signed. ### Step 1: Switch to "Production" in the Web App The first step is to switch your web app to the Production environment. This can be accomplished within your Portal web app. Follow the steps outlined below: 1. [Log into](https://app.portalhq.io) your Portal account. 2. Locate the **Environment** dropdown in the bottom-right corner of your screen. 3. Switch it to **Production**. ### Step 2: Create a Production API Key The next step is to create an API key that will be used in the Production environment. Here's how to do it: 1. After switching to the Production environment, navigate to the **API Keys** section on the Settings page. 2. Click on the **New +** button. 3. A modal will pop up. Give your new Production API key a recognizable name. For instance, "Production Key". 4. Click on **Create**. After clicking Create, your new Production API key is generated. You can now use this key to create production clients via the REST API endpoint. This will ensure that all clients in your Production environment will be logically separated from your Development environment, and restrict access to only your Production API key. ### Step 3: Configure a Production Self-Managed Backup Webhook (Optional) This step only applies if you chose to use [Self-Managed Backups](./self-managed-backups) to store backup shares on your own backend instead of Portal Managed Backup which stores the backup shares in Portal's backend. If so follow the Self-Managed Backup instructions to: 1. Set up your [database schema](./self-managed-backups#your-database) to store Portal backup shares. 2. Spin up a [webhook server](./self-managed-backups#api-endpoints-you-need-to-build) to accept backup and recovery webhook requests. Be sure to add IP Allowlisting and authenticate the Webhook Secret. 3. Update the [Portal webhook configuration](./self-managed-backups#configuration) with your webhook URL. Remember, this should be a different configuration than your Development environment setup. Congratulations! You are now set up to create Production wallets with Portal. If you encounter any issues or have further questions, please don't hesitate to contact Portal support. # Overview Source: https://docs.portalhq.io/resources/hackathon-hub All of the information you need to get started for a hackathon using Portal! ### Getting Started To get started head to the [Portal Community Slack](https://join.slack.com/t/portalcommunity/shared_invite/zt-1zlyexf43-A94Z4ugntrgLGs2j5slmeQ) to request access to the [Portal Admin Dashboard](https://app.portalhq.io/login) where you can get credentials to start developing with Portal. ### What is Portal? **Portal** is an [MPC wallet](./portals-mpc-architecture) provider and web3 developer platform. You can use Portal to create embedded MPC wallets for users and the Portal SDK to build out the web3 functionality for your application. ## General Hackathon Kits These are our general-purpose hackathon kit repositories that can be used as a starting point for any hackathon: * **Web (Typescript):** [https://github.com/portal-hq/portal-hackathon-kit-web](https://github.com/portal-hq/portal-hackathon-kit-web-mobil3) * **Android:** [https://github.com/portal-hq/portal-hackathon-kit-android](https://github.com/portal-hq/portal-hackathon-kit-android-mobil3) * **iOS:** [https://github.com/portal-hq/portal-hackathon-kit-ios](https://github.com/portal-hq/portal-hackathon-kit-ios-mobil3) * **React Native:** [https://github.com/portal-hq/portal-hackathon-kit-react-native](https://github.com/portal-hq/portal-hackathon-kit-react-native-mobil3) * **Enclave MPC API:** [https://github.com/portal-hq/portal-hackathon-kit-api](https://github.com/portal-hq/portal-hackathon-kit-api-mobil3) For hackathon-specific kits and resources, check the sub-pages for each active hackathon. ## Hackathons * [Mobil3 Hackathon](./hackathons/mobil3) ## Portal Documentation ### Portal SDKs Portal offers SDKs for various platforms to suit your development needs. * [Portal SDK Quickstart](../sdks/quickstart): This guide will walk you through the steps to get started with the Portal SDK. * [Creating a Wallet](../sdks/web/guide/create-a-wallet): This guide will walk you through the steps to create a wallet using the Portal SDK. * [Sending Tokens](../sdks/web/guide/send-tokens): This guide will walk you through the steps to send tokens to a given address. ### Portal APIs Portal provides several APIs to enhance your hackathon projects. * [Enclave MPC API Quickstart](../apis/quickstart): This guide will walk you through the steps to get started with the Enclave MPC API. * [Client API](../apis/client/reference): Use this API to manage Portal clients and their wallets with endpoints for assets, transactions, and more. * [Custodian API](../apis/custodian/reference): This API helps you manage your own custodian and your Portal clients' wallets with specialized endpoints. * [Get Assets Endpoint](../apis/client/reference#get-assets-by-chain): This endpoint returns a list of fungible assets (native, ERC-20, and SPL tokens) associated with your Portal client for a given chain. * [Get Transaction History Endpoint](../apis/client/reference#get-transactions-by-chain): This endpoint returns a list of the historic transactions associated with your Portal client for a given chain. * [View the full Client API reference](../apis/client/reference) # Mobil3 Hackathon Source: https://docs.portalhq.io/resources/hackathons/mobil3 Resources for the Mobil3 Hackathon using Portal ### Event Overview **[Mobil3 Hackathon](https://mobil3.xyz/) - Building the Mobile Web3 Future in Latin America** * **Dates:** August 20-24, 2025 * **Location:** Mexico City, Mexico * **Venue:** Publi.co Reforma * **Focus:** Mobile-first crypto solutions for real-world challenges * **Blockchain:** Monad **Prizes:** \$90,000 USD in cash prizes + 1-week fully-sponsored Monad Builder Residency in NYC **Apply:** [https://mobil3.xyz/](https://mobil3.xyz/) ### Getting Started To get started head to the [Portal Community Slack](https://join.slack.com/t/portalcommunity/shared_invite/zt-1zlyexf43-A94Z4ugntrgLGs2j5slmeQ) to request access to the [Portal Admin Dashboard](https://app.portalhq.io/login) where you can get credentials to start developing with Portal. ### Mobil3 Hackathon Kits Get started with one of our Mobil3-specific hackathon kit repositories: * **Web (Typescript):** [https://github.com/portal-hq/portal-hackathon-kit-web-mobil3](https://github.com/portal-hq/portal-hackathon-kit-web-mobil3) * **Android:** [https://github.com/portal-hq/portal-hackathon-kit-android-mobil3](https://github.com/portal-hq/portal-hackathon-kit-android-mobil3) * **iOS:** [https://github.com/portal-hq/portal-hackathon-kit-ios-mobil3](https://github.com/portal-hq/portal-hackathon-kit-ios-mobil3) * **React Native:** [https://github.com/portal-hq/portal-hackathon-kit-react-native-mobil3](https://github.com/portal-hq/portal-hackathon-kit-react-native-mobil3) * **Enclave MPC API:** [https://github.com/portal-hq/portal-hackathon-kit-api-mobil3](https://github.com/portal-hq/portal-hackathon-kit-api-mobil3) Account Abstraction support for Monad Testnet is coming soon! ### Monad Resources * [Monad Foundation](https://monad.xyz/): Learn about the high-performance EVM blockchain * [Monad Testnet](https://testnet.monad.xyz/): Get started with Monad testnet * [Monad Builder Resources](https://docs.monad.xyz/): Comprehensive documentation for building on Monad ### Faucets * [Monad Testnet Faucet](https://faucet.monad.xyz/): Get testnet tokens for development # Idempotency Keys Source: https://docs.portalhq.io/resources/idempotency-keys Prevent duplicate transactions by including an idempotency key with your requests. When broadcasting transactions through Portal's Enclave MPC API, network failures, timeouts, or client-side retries can result in duplicate operations. Idempotency keys provide a mechanism to safely retry requests without the risk of processing the same operation twice. ### Supported endpoints Idempotency keys are supported on the following Enclave MPC API endpoints: | Method | Endpoint | Applicable Conditions | | ------ | ----------------------------------------------- | -------------------------- | | `POST` | `https://mpc-client.portalhq.io/v1/assets/send` | All requests. | | `POST` | `https://mpc-client.portalhq.io/v1/sign` | See Broadcast RPC methods. | Idempotency keys are optional. If the `Idempotency-Key` header is omitted, the request is processed normally without idempotency enforcement. ### Broadcast RPC methods Idempotency keys are only compatible with RPC methods that broadcast transactions. | Chain(s) | RPC method(s) | | -------------- | ------------------------------------------------------------- | | Ethereum / EVM | `eth_sendTransaction` | | Solana | `sol_signAndSendTransaction`, `sol_signAndConfirmTransaction` | | Tron | `tron_sendTransaction` | | Stellar | `stellar_sendTransaction` | ### How it works 1. Include the `Idempotency-Key` header with a unique value in your request. 2. Portal creates a record with status `PENDING` and processes the request normally. 3. On a successful broadcast via an RPC provider, the record is updated to `BROADCASTED`. On failure, it is updated to `FAILED`. 4. Any subsequent request with the same idempotency key from the same client is rejected with a `409` — it will not be re-processed, regardless of the original request's outcome. If a request stays in `PENDING` status for more than 10 minutes (for example, due to an unexpected process interruption), it is automatically marked as `UNKNOWN`. ### Expected response handling flow Follow the expected flow in order to safely retry, handle response codes, and to protect yourself against double spending. 1. Submit a transaction with a unique idempotency key. 2. Add retry logic to handle failures and uncertain outcomes: a. if the status code is `409` and the `id` is `IDEMPOTENT_REQUEST_IN_PROGRESS` then retry with backoff. b. if the status code is `409` and the `id` is `IDEMPOTENT_REQUEST_ALREADY_COMPLETED` then stop retrying. The transaction has been broadcast. c. if the status code is `409` and the `id` is `IDEMPOTENT_REQUEST_PREVIOUSLY_FAILED` then stop retrying. The transaction failed cleanly. Follow error handling logic to re-submit with a different idempotency key. It is safe to submit again, without risk of double spending. d. if the status code is `409` and the `id` is `IDEMPOTENT_REQUEST_UNEXPECTED_STATE` then stop retrying. The transaction has failed in an unknown state. Follow error handling logic. You may not want to automatically resend without verifying balances first. It is **not** safe to spend again. You are at risk of double spending. e. if the status code is `422` then stop retrying. You are submitting different transactions with the same idempotency key. Follow error handling logic. ### Key format | Rule | Detail | | ------------------ | ------------------------------------------------------------------------ | | Max length | 255 characters | | Allowed characters | `A-Z`, `a-z`, `0-9`, `-`, `.`, `_`, `~` (RFC 3986 unreserved characters) | V4 UUIDs are a good default choice for idempotency keys (e.g. `550e8400-e29b-41d4-a716-446655440000`). ### Key expiration Idempotency keys expire after **24 hours**. After expiration, the same key value can be reused for a new request. ### Response behavior | Scenario | Status | Error ID | | ----------------------------------------------- | ------ | -------------------------------------- | | First request with a new key | `200` | — | | Same key, same body — original still processing | `409` | `IDEMPOTENT_REQUEST_IN_PROGRESS` | | Same key, same body — original succeeded | `409` | `IDEMPOTENT_REQUEST_ALREADY_COMPLETED` | | Same key, same body — original failed | `409` | `IDEMPOTENT_REQUEST_PREVIOUSLY_FAILED` | | Same key, same body — original in unknown state | `409` | `IDEMPOTENT_REQUEST_UNEXPECTED_STATE` | | Same key, **different** body | `422` | `IDEMPOTENCY_KEY_REUSED` | Once a request is made with an idempotency key, that key cannot be reused for any new request within the 24-hour expiration window — even if the original request failed. Generate a new key for each new operation. ### Error format Idempotency errors follow the same structure as other MPC errors documented on the [Error Codes](/resources/error-codes) page: ```json theme={null} { "id": "IDEMPOTENT_REQUEST_ALREADY_COMPLETED", "message": "Request already completed" } ``` ### Example ```bash theme={null} curl --request POST \ --url https://mpc-client.portalhq.io/v1/assets/send \ --header 'Authorization: Bearer [token]' \ --header 'Content-Type: application/json' \ --header 'Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000' \ --data '{ "share": "share", "chain": "ethereum", "to": "0xdFd8302f44727A6348F702fF7B594f127dE3A902", "token": "NATIVE", "amount": "0.0001" }' ``` ### Best practices * **Generate a unique key per request**: Use a V4 UUID or another random identifier with enough entropy to avoid collisions. * **Store the key before sending**: Persist the key to your database or logs before making the request, so you can correlate retries. * **Reuse the same key only for identical retries**: If you need to retry the exact same operation with the same request body, reuse the key. If any parameters change, generate a new key. * **Handle rejection responses gracefully**: When you receive a `409` or `422` response, check the `id` field to determine whether the original request is still processing, already succeeded, or failed. * **Use distinct keys per logical operation**: Each independent transaction or signing operation should have its own unique idempotency key. # Migrating from legacy Solana alert webhook events Source: https://docs.portalhq.io/resources/migrating-from-solana-legacy-webhooks Upgrade from the legacy Solana alert webhook events to the current ones to get per-address delivery, the `triggeredBy` field, and new properties. The current Solana alert webhook events supersede the legacy ones. They change the delivery model from "one event per transaction" to "one event per address involved", and introduce a new event type for delegated transfers along with new payload fields. This guide is for customers still consuming the legacy Solana alert webhook events who want to migrate. ## What's changing * **Per-address delivery.** The legacy webhook sent a single event per Solana transaction. The new webhook sends **one event per subscribed address involved** in the transaction. If two of your Portal clients are involved in a single Solana transaction (e.g. one sends SOL to the other), you will now receive **two** webhook events for that transaction — each with a different `triggeredBy` value and the same `signature`. * **New event types.** Each legacy event type has a replacement, plus a brand-new event for delegated transfers: * **`SOLANA_TX_V2`** — Solana native (SOL) transfers and SPL token transfers. *(Replaces `SOLANA_TX_V1`, without delegated transfers.)* * **`SOLANA_APPROVE_V2`** — Solana delegation approve transactions. *(Replaces `SOLANA_APPROVAL_V1`.)* * **`SOLANA_REVOKE_V2`** — Solana delegation revoke transactions. *(Replaces `SOLANA_REVOKE_V1`.)* * **`SOLANA_DELEGATED_TRANSFER_V2`** — **New event.** Fires when a delegate executes a transfer using funds from another user's previously-approved delegation. If you don't subscribe to `SOLANA_DELEGATED_TRANSFER_V2`, you will not be notified when a delegate moves tokens that one of your addresses had previously approved. To preserve parity with the legacy "every transfer involving my addresses" behavior, subscribe to **both** `SOLANA_TX_V2` and `SOLANA_DELEGATED_TRANSFER_V2`. * **`triggeredBy` field.** Every event payload now includes a `triggeredBy` field. It identifies **which of your subscribed addresses** caused this specific event delivery. Use this to route events to the right user/account on your side without re-deriving it from the raw transaction data. * **`direction` field.** Every event payload also includes a `direction` field that describes what the `triggeredBy` address did in the transaction: | Value | Meaning | | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `OUTBOUND` | `triggeredBy` is the sender of the transaction — the one who sent native or non-native tokens, who approved or revoked a delegation, or who executed a delegated transfer. | | `INBOUND` | `triggeredBy` is the receiver of the transaction — the one who received tokens, or who was granted approval to spend delegated tokens. | | `DELEGATED` | Only on `SOLANA_DELEGATED_TRANSFER_V2` events: `triggeredBy` is the owner whose tokens are being moved by the delegate (the source of funds in the delegated transfer, distinct from the delegate that executed it). | * **`recentBlockhash` field.** Every event payload now includes a `recentBlockhash` field — the base58-encoded `recentBlockhash` of the transaction. ## Payload differences The same Solana transfer (a `0.01 SOL` receive) shows up like this on the legacy webhook vs the new one. Note the new `triggeredBy` and `direction` fields, and the `type` value changing from `SOLANA_TX_V1` to `SOLANA_TX_V2`. ```json theme={null} { "data": { "rawEvents": [ { "fee": 80000, "slot": 379135212, "type": "TRANSFER", "events": {}, "source": "SYSTEM_PROGRAM", "feePayer": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "signature": "5hyWP82Fn951zjo5DHWAvAC8NjwU7LAKqR3Rpn7sB1H159koer8BefYSSCRuC7X3xVy6rHvMzM7sYHLBwxTAEeUY", "timestamp": 1746565669, "accountData": [ { "account": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "nativeBalanceChange": -10080000, "tokenBalanceChanges": [] }, { "account": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "nativeBalanceChange": 10000000, "tokenBalanceChanges": [] } ], "description": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H transferred 0.01 SOL to 67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx.", "tokenTransfers": [], "nativeTransfers": [ { "amount": 10000000, "toUserAccount": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "fromUserAccount": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H" } ], "transactionError": null } ] }, "metadata": { "isDevnet": true, "eventTypes": ["TRANSFER"], "environmentId": "environmentId" }, "type": "SOLANA_TX_V1" } ``` ```json theme={null} { "data": { "rawEvents": [ { "fee": 80000, "slot": 379135212, "type": "TRANSFER", "events": {}, "source": "SYSTEM_PROGRAM", "feePayer": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "signature": "5hyWP82Fn951zjo5DHWAvAC8NjwU7LAKqR3Rpn7sB1H159koer8BefYSSCRuC7X3xVy6rHvMzM7sYHLBwxTAEeUY", "timestamp": 1746565669, "triggeredBy": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "direction": "INBOUND", "accountData": [ { "account": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "nativeBalanceChange": -10080000, "tokenBalanceChanges": [] }, { "account": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "nativeBalanceChange": 10000000, "tokenBalanceChanges": [] } ], "description": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H transferred 0.01 SOL to 67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx.", "tokenTransfers": [], "nativeTransfers": [ { "amount": 10000000, "toUserAccount": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "fromUserAccount": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H" } ], "recentBlockhash": "2d7rAqbS4ZFsroMHkxA8kmJdvmoPy4mtZRqCpSNTa1DQ", "transactionError": null } ] }, "metadata": { "isDevnet": true, "eventTypes": ["TRANSFER"], "environmentId": "environmentId" }, "type": "SOLANA_TX_V2" } ``` ## Event payloads Below is a full example payload for each new event type so you can wire up parsers and types ahead of cutting over. ### `SOLANA_TX_V2` ```json theme={null} { "data": { "rawEvents": [ { "fee": 80000, "slot": 379135212, "type": "TRANSFER", "events": {}, "source": "SYSTEM_PROGRAM", "feePayer": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "signature": "5hyWP82Fn951zjo5DHWAvAC8NjwU7LAKqR3Rpn7sB1H159koer8BefYSSCRuC7X3xVy6rHvMzM7sYHLBwxTAEeUY", "timestamp": 1746565669, "triggeredBy": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "direction": "INBOUND", "accountData": [ { "account": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "nativeBalanceChange": -10080000, "tokenBalanceChanges": [] }, { "account": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "nativeBalanceChange": 10000000, "tokenBalanceChanges": [] }, { "account": "11111111111111111111111111111111", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "ComputeBudget111111111111111111111111111111", "nativeBalanceChange": 0, "tokenBalanceChanges": [] } ], "description": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H transferred 0.01 SOL to 67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx.", "instructions": [ { "data": "3b1H8Rq1T3d1", "accounts": [], "programId": "ComputeBudget111111111111111111111111111111", "innerInstructions": [] }, { "data": "LKoyXd", "accounts": [], "programId": "ComputeBudget111111111111111111111111111111", "innerInstructions": [] }, { "data": "3Bxs4NN8M2Yn4TLb", "accounts": [ "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx" ], "programId": "11111111111111111111111111111111", "innerInstructions": [] } ], "tokenTransfers": [], "nativeTransfers": [ { "amount": 10000000, "toUserAccount": "67vPQk4SMRRmZdcfyRd6kBggyeca6TqWmsAkJatNDHyx", "fromUserAccount": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H" } ], "recentBlockhash": "2d7rAqbS4ZFsroMHkxA8kmJdvmoPy4mtZRqCpSNTa1DQ", "transactionError": null } ] }, "metadata": { "isDevnet": true, "eventTypes": ["TRANSFER"], "environmentId": "environmentId" }, "type": "SOLANA_TX_V2" } ``` ### `SOLANA_APPROVE_V2` ```json theme={null} { "data": { "rawEvents": [ { "fee": 10000, "slot": 438851116, "type": "APPROVE", "events": {}, "source": "SOLANA_PROGRAM_LIBRARY", "feePayer": "98aauPkabddWQVyHWNw5LJLDb84ETKfN2xYUy1Zr2sQy", "signature": "7fLggf2PBHnH8QfWTzWjjpXKcyUj9h2roR6tCBxAAHJoiLyuYEDx95RqJNz1vnjFSJ8vyaZpanCgEHVLdVvX9Ng", "timestamp": 1769837881, "triggeredBy": "J4AVMHNUkxhjUmWg25RTfysAEx7FnRYymT2xZkc12sKK", "direction": "OUTBOUND", "accountData": [ { "account": "98aauPkabddWQVyHWNw5LJLDb84ETKfN2xYUy1Zr2sQy", "nativeBalanceChange": -10000, "tokenBalanceChanges": [] }, { "account": "J4AVMHNUkxhjUmWg25RTfysAEx7FnRYymT2xZkc12sKK", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "pQxS4DANX5uMkDkzJSk2dSBnrEWZ1enUL2XkKF6Nhbh", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "nativeBalanceChange": 0, "tokenBalanceChanges": [] } ], "description": "", "instructions": [ { "data": "3xHiPX82ia9u", "accounts": [ "pQxS4DANX5uMkDkzJSk2dSBnrEWZ1enUL2XkKF6Nhbh", "75ZfLXXsSpycDvHTQuHnGQuYgd2ihb6Bu4viiCCQ7P4H", "J4AVMHNUkxhjUmWg25RTfysAEx7FnRYymT2xZkc12sKK" ], "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "innerInstructions": [] } ], "tokenTransfers": [], "nativeTransfers": [], "recentBlockhash": "2d7rAqbS4ZFsroMHkxA8kmJdvmoPy4mtZRqCpSNTa1DQ", "transactionError": null } ] }, "metadata": { "isDevnet": true, "eventTypes": ["APPROVE"], "environmentId": "environmentId" }, "type": "SOLANA_APPROVE_V2" } ``` ### `SOLANA_REVOKE_V2` ```json theme={null} { "data": { "rawEvents": [ { "fee": 10000, "slot": 438851202, "type": "REVOKE", "events": {}, "source": "SOLANA_PROGRAM_LIBRARY", "feePayer": "98aauPkabddWQVyHWNw5LJLDb84ETKfN2xYUy1Zr2sQy", "signature": "3p4qVucEtUhZ3GbsQv3ZQS2mTRsziSgJs6B7gre34X2xnG2WPkaNvHuk9wDRJ2kKzKa8M7Rmykpr6wTtYLRuY9oh", "timestamp": 1769837914, "triggeredBy": "98aauPkabddWQVyHWNw5LJLDb84ETKfN2xYUy1Zr2sQy", "direction": "OUTBOUND", "accountData": [ { "account": "98aauPkabddWQVyHWNw5LJLDb84ETKfN2xYUy1Zr2sQy", "nativeBalanceChange": -10000, "tokenBalanceChanges": [] }, { "account": "J4AVMHNUkxhjUmWg25RTfysAEx7FnRYymT2xZkc12sKK", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "pQxS4DANX5uMkDkzJSk2dSBnrEWZ1enUL2XkKF6Nhbh", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "nativeBalanceChange": 0, "tokenBalanceChanges": [] } ], "description": "", "instructions": [ { "data": "6", "accounts": [ "pQxS4DANX5uMkDkzJSk2dSBnrEWZ1enUL2XkKF6Nhbh", "J4AVMHNUkxhjUmWg25RTfysAEx7FnRYymT2xZkc12sKK" ], "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "innerInstructions": [] } ], "tokenTransfers": [], "nativeTransfers": [], "recentBlockhash": "2d7rAqbS4ZFsroMHkxA8kmJdvmoPy4mtZRqCpSNTa1DQ", "transactionError": null } ] }, "metadata": { "isDevnet": true, "eventTypes": ["REVOKE"], "environmentId": "environmentId" }, "type": "SOLANA_REVOKE_V2" } ``` ### `SOLANA_DELEGATED_TRANSFER_V2` ```json theme={null} { "data": { "rawEvents": [ { "fee": 10000, "slot": 413227505, "type": "DELEGATED_TRANSFER", "events": {}, "source": "SOLANA_PROGRAM_LIBRARY", "feePayer": "2eWxXEVjmyBxaJCPRmZJqtpYm7wkyXfmcNAwFPHBpwLx", "signature": "5BFuHM8bMUVfHuwFc4DHoPQsYMbwhYWLGkjv41Lu5dn4wuRb23mK5oDJirjQxKadGYYWzTmSYnUMUed3Reef6Mxx", "timestamp": 1776194807, "triggeredBy": "9Nmpej98v2kEAXtc4AD52EbQAFrc3ikRe881LehqsEHD", "direction": "INBOUND", "accountData": [ { "account": "2eWxXEVjmyBxaJCPRmZJqtpYm7wkyXfmcNAwFPHBpwLx", "nativeBalanceChange": -10000, "tokenBalanceChanges": [] }, { "account": "9rG4md1zvwpP1NtjMg48HutvuWYaHXerYat2C2E4PAz7", "nativeBalanceChange": 0, "tokenBalanceChanges": [] }, { "account": "C2NWfgp2qUb2papQzy28XQPCxeT84zDLSPJRZvXGeZBp", "nativeBalanceChange": 0, "tokenBalanceChanges": [ { "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "userAccount": "49bNwvPHy3krxWLBU3qrK5Gm1RPFqWDXB8xaawcjyCjd", "tokenAccount": "C2NWfgp2qUb2papQzy28XQPCxeT84zDLSPJRZvXGeZBp", "rawTokenAmount": { "decimals": 6, "tokenAmount": "-1" } } ] }, { "account": "C8wu1R39Brrbf8BrKB6uNQbv1pr43RopsQ2LknfdHa6A", "nativeBalanceChange": 0, "tokenBalanceChanges": [ { "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "userAccount": "9Nmpej98v2kEAXtc4AD52EbQAFrc3ikRe881LehqsEHD", "tokenAccount": "C8wu1R39Brrbf8BrKB6uNQbv1pr43RopsQ2LknfdHa6A", "rawTokenAmount": { "decimals": 6, "tokenAmount": "1" } } ] }, { "account": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "nativeBalanceChange": 0, "tokenBalanceChanges": [] } ], "description": "9rG4md1zvwpP1NtjMg48HutvuWYaHXerYat2C2E4PAz7 transferred 0.000001 USDC to 9Nmpej98v2kEAXtc4AD52EbQAFrc3ikRe881LehqsEHD.", "instructions": [ { "data": "3DdGGhkhJbjm", "accounts": [ "C2NWfgp2qUb2papQzy28XQPCxeT84zDLSPJRZvXGeZBp", "C8wu1R39Brrbf8BrKB6uNQbv1pr43RopsQ2LknfdHa6A", "9rG4md1zvwpP1NtjMg48HutvuWYaHXerYat2C2E4PAz7" ], "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "innerInstructions": [] } ], "tokenTransfers": [ { "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "tokenAmount": 0.000001, "toUserAccount": "9Nmpej98v2kEAXtc4AD52EbQAFrc3ikRe881LehqsEHD", "tokenStandard": "Fungible", "toTokenAccount": "C8wu1R39Brrbf8BrKB6uNQbv1pr43RopsQ2LknfdHa6A", "fromUserAccount": "9rG4md1zvwpP1NtjMg48HutvuWYaHXerYat2C2E4PAz7", "fromTokenAccount": "C2NWfgp2qUb2papQzy28XQPCxeT84zDLSPJRZvXGeZBp" } ], "nativeTransfers": [], "recentBlockhash": "2d7rAqbS4ZFsroMHkxA8kmJdvmoPy4mtZRqCpSNTa1DQ", "transactionError": null } ] }, "metadata": { "isDevnet": false, "eventTypes": ["DELEGATED_TRANSFER"], "environmentId": "environmentId" }, "type": "SOLANA_DELEGATED_TRANSFER_V2" } ``` ## Migration steps 1. **Expect multiple events per transaction.** If two or more of your subscribed addresses are involved in the same Solana transaction, you will receive **one event per address**. Update your event handlers to be safe under this fan-out. * **Idempotency between transactions:** dedupe on the `signature` field. Solana transactions are uniquely identified by their `signature`, so it works as a stable idempotency key for "have I seen this transaction before?". * **Idempotency per (transaction, address):** dedupe on the composite key `signature` + `triggeredBy`. Use this when you process events per-address, so each `(transaction, address)` pair is processed exactly once even if the webhook is retried. 2. **Subscribe to the events you need from the Portal Admin Dashboard.** In the **Alert Webhooks** settings, select the event types your integration cares about: `SOLANA_TX_V2`, `SOLANA_APPROVE_V2`, `SOLANA_REVOKE_V2`, and/or `SOLANA_DELEGATED_TRANSFER_V2`. If you want to keep parity with the legacy webhook — i.e. be notified about **every** transfer involving your addresses — subscribe to **both** `SOLANA_TX_V2` and `SOLANA_DELEGATED_TRANSFER_V2`. Without `SOLANA_DELEGATED_TRANSFER_V2`, transfers executed by a delegate against an approval that one of your addresses granted will not generate any webhook event. 3. **Test that events are being received with the correct types.** Send a test transaction (devnet works well) and confirm: * You receive an event whose `type` ends in `_V2` (not `_V1`). * The payload contains both `triggeredBy` and `direction`. * When two of your subscribed addresses are involved in the same transaction, you receive **two** events with different `triggeredBy` values and a shared `signature`. # Policies Source: https://docs.portalhq.io/resources/policies On this page, we'll go through setting up transaction policies to control what your users' wallets are allowed to sign. Policies are environment-wide rules that Portal enforces directly in the MPC signing path, before any signature is produced. Unlike checks you might add in your own application layer, a blocked transaction never gets signed at all. There's no signature for a client to work around or replay. Policies are managed from the Policies page on the [Portal Admin Dashboard](https://app.portalhq.io). ## The transaction amount limit rule Policy Engine v1 ships with one rule kind: a transaction amount limit. For each policy you choose: * A **name** (up to 64 characters), which appears in the error when the policy blocks a transaction * A **chain** * A **token** — the chain's native asset, a listed stablecoin, or a custom token (contract address on EVM/Tron, or mint address on Solana) along with its decimals * A **per-transaction cap**, entered in display units (e.g. `0.5` ETH, `100` USDC) Any single transaction that moves more than the cap for that chain and token is blocked. ## Supported chains * **EVM chains** — Ethereum, Sepolia, Polygon, Base, and Monad (mainnet and testnet) out of the box; amount extraction works on any `eip155` chain * **Solana** — mainnet and devnet * **Tron** — mainnet, Shasta, and Nile testnets ## How enforcement behaves All enabled policies in an environment are evaluated for every transaction. If any policy's rule matches, the transaction is blocked; if none match, the transaction proceeds. There's no allowlist mode in this version. Policies are purely additive blockers. When a policy blocks a transaction, the request fails with HTTP `400` and no signature is ever produced. Both `POST /v1/assets/send` and `POST /v1/sign` return the same body: ```json theme={null} { "id": "POLICY_AMOUNT_LIMIT_EXCEEDED", "message": "amount exceeds limit ()" } ``` That is the raw Enclave MPC API response body; Portal SDKs expose the same fields through their own error handling. Match on the [`POLICY_AMOUNT_LIMIT_EXCEEDED`](/resources/error-codes) identifier rather than on the message text. Responses also carry a legacy numeric `code`, which is deprecated. ## Managing policies on the dashboard Step 1: Click on "Policies" in the left sidebar, under Configuration. Step 2: Click "Create policy," choose a chain and token, set your per-transaction cap, and save. Each policy holds one rule. Once created, a policy appears in your list with a toggle and a delete action: * **Toggle** — enable or disable the policy without deleting it. Disabled policies are not evaluated. * **Delete** — permanently remove the policy. ## Scope & caveats Policy Engine v1 is intentionally scoped. A few things to keep in mind: * **Per-transaction only.** Limits are compared against a single transaction's amount. Velocity or cumulative limits (e.g. "no more than \$1,000 per day") are on the roadmap but not yet available. * **Direct transfers are covered, not everything that moves value.** Token limits inspect direct `transfer`/`transferFrom` calls on EVM and Tron, and SPL transfers on Solana, both `TransferChecked` and plain `Transfer` sent from a wallet's associated token account. Value moved through arbitrary contract calls, approve-then-pull flows, or non-standard token accounts is not currently inspected. * **Raw-signed payloads can't be policy-checked.** If your integration signs an opaque hash rather than a structured transaction, Portal has no transaction contents to evaluate against a policy, by design. If your use case depends on any of the above, reach out to your Portal contact to discuss options. # Portal's MPC architecture Source: https://docs.portalhq.io/resources/portals-mpc-architecture This guide will go over the basics of Portal's MPC architecture for generating, backing up, and recovering a user's wallet. ### Overview Portal leverages Multi-Party Computation (MPC) and specifically Threshold Signature Schemes (TSS) to allow multiple parties to sign transactions across key shares instead of a single private key to manage access to a crypto wallet that is traditionally used for crypto wallets. Portal's implementation of MPC uses two sets of key share pairs (parallel 2 of 2 threshold - four shares total). Key shares are never reconstructed for signatures and generated and conducts signatures independently across each party involved. Portal leverages an open-source and audited MPC protocol called CGGMP. Please reach out to learn more about our implementation and audits. ### Step 1: Generating a wallet The **signing key shares** are used for signing transactions and the **backup key shares** are used for recovery if the device storing a signing key shares is lost. MPC Wallet Generation diagram The user's **signing key share** is stored in their **mobile device's keychain**. The Portal signing key share is stored encrypted-at-rest. Learn more about Portal's security practices [here](https://www.portalhq.io/post/portal-security-principles-and-practices). ### Step 2: Backing up a wallet The user's **backup share** is encrypted and sent to the **organization** where it is stored encrypted-at-rest by backup method. The decryption key for the user's backup share is sent to **Google Drive or iCloud.** The **custodian backup share** is also sent to the **organization** to store encrypted-at rest by backup method. Portal only stores the **signing key share**, and so can only sign messages with the key share stored in the device's keychain. Likewise, to perform a recovery using the user's **backup share** only the **backup share** stored by the **organization** will work. Only the **user signing share** can initiate transactions as all signatures may only originate from the user client. This separation of concerns provides additional security constraints to ensure that a compromise of any one key share has additional authentication methods to sign an MPC transaction. MPC Backup Mechanism Setup diagram MPC backups allow your users to recover their MPC wallets in the event that their device is lost, stolen, or broken. Backups are handled in two pieces: **user backup shares** and **custodian backup shares (organization backup shares)**. At the time of recovery, these two backups are **used together** to generate new shares to be stored on-device, allowing the user to migrate their wallet with a new device. Note: Portal shares (blue) and custodian shares (purple) are intentionally mismatched and parts of different signing thresholds and therefore cannot collude and sign transactions on behalf of users. ### Step 3: Recovering a wallet MPC recovery allows your users to recover their MPC wallets in the event that their device is lost, stolen, or broken. MPC recovery uses the MPC backups that a user has already ran to create a new set of signing shares. Recovery is handled in two pieces: **user backup share recovery** and **custodian backup share recovery**. MPC Recovery diagram Recovery replaces a user's old signing shares on the device (if there were any) with a new pair of **signing shares**. # Pre-Generated Wallets Source: https://docs.portalhq.io/resources/pre-generated-wallets Speed up wallet creation by claiming a share from a pre-computed pool instead of running the interactive MPC protocol in real time. ## Overview Wallet creation normally runs the MPC key generation (DKG) protocol interactively between the device and the MPC servers at the moment `createWallet` is called. Pre-generated wallets let the SDK instead claim a share that was already computed ahead of time from a standing pool, which can make wallet creation noticeably faster. This is entirely opt-in and transparent to your integration: you don't call any new APIs, and the shape of `createWallet` doesn't change. You only need to enable a feature flag when initializing Portal. ## How It Works | Step | Behavior | | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Flag disabled (default)** | `createWallet` runs the standard interactive DKG protocol, exactly as it does today. | | **Flag enabled** | `createWallet` first attempts to claim a pre-generated share. If that succeeds, wallet creation completes faster than the standard flow. | | **Pool temporarily unavailable** | If claiming a pre-generated share fails with an HTTP 5xx response from the enclave, the SDK automatically falls back to the standard interactive DKG protocol. The fallback is transparent—`createWallet` resolves or rejects exactly as it would through the standard flow. | | **Any other failure** | Errors that aren't a 5xx (for example, a malformed request, an invalid API key, or a network failure) are returned as-is, since retrying with the standard flow wouldn't resolve them. | ## Key Details | Property | Details | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Default** | Disabled | | **Fallback scope** | Automatic fallback to standard generation only occurs on an HTTP 5xx response from the enclave. Other errors, including network failures, propagate normally. | | **API surface** | No change—continue calling `createWallet` exactly as before. | | **Wallet result** | Identical to a wallet created through the standard flow; there's no difference in how the resulting wallet is used, backed up, or recovered. | ## Best Practices * **Treat it as a pure performance optimization.** Enable the flag to reduce wallet creation latency; no other integration changes are needed. * **Keep your existing error handling around `createWallet`.** The SDK already falls back automatically where it can—your existing error handling for `createWallet` continues to cover the cases that aren't recoverable. ## SDK behavior Enable pre-generated wallets with the **`usePreGeneratedWallet`** feature flag. With the flag enabled, the SDK attempts to claim a pre-generated share on `createWallet` and falls back to the standard MPC generation flow automatically if that attempt fails with an HTTP 5xx response from the enclave. ## Next Steps Ready to use pre-generated wallets? Choose your platform: * **Android** — Enable the [`usePreGeneratedWallet`](/sdks/android/guide/feature-flags#usepregeneratedwallet) feature flag when initializing Portal * **Flutter** — Enable the [`usePreGeneratedWallet`](/sdks/flutter/guide/feature-flags#usepregeneratedwallet) feature flag when initializing Portal * **iOS** — Enable the [`usePreGeneratedWallet`](/sdks/ios/guide/feature-flags#2-usepregeneratedwallet) feature flag when initializing Portal * **React Native** — Enable the [`usePreGeneratedWallet`](/sdks/react-native/guide/feature-flags#usepregeneratedwallet) feature flag when initializing Portal * **Web** — Enable the [`usePreGeneratedWallet`](/sdks/web/guide/feature-flags#usepregeneratedwallet) feature flag when initializing Portal # Presignatures Source: https://docs.portalhq.io/resources/presignatures Pre-compute MPC signing data ahead of time for faster transaction signing. ## Overview Presignatures allow you to split the MPC signing protocol into two phases: 1. **Pre-computation phase** — Generate a presignature ahead of time using the `presign` endpoint. 2. **Signing phase** — Pass the presignature to a sign, raw sign, or send asset endpoint to complete the signature faster. By front-loading part of the cryptographic computation, the signing step completes faster. This is useful when you need lower-latency signing at transaction time or want to prepare signatures in advance for time-sensitive operations. ## How It Works The Enclave MPC API supports two presignature storage modes: | Mode | How it works | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Client-stored** | The presign endpoint returns an opaque `data` blob. Your application stores it and later passes it as `presignature` in a signing request. | | **Portal-managed** | Portal stores the encrypted client presignature payload. Your application stores only the returned `id` and later passes it as `presignatureId` in a signing request. | In both modes, the signing endpoint uses the pre-computed data to complete the MPC protocol faster. Presignatures workflow ## Key Details | Property | Details | | ---------------------- | ------------------------------------------------------------------------- | | **Supported curves** | `SECP256K1` (EVM, Bitcoin). ED25519 (Solana) support coming soon. | | **Default expiration** | 7 days from creation | | **Maximum expiration** | 365 days from creation | | **Active limit** | 100 presignatures per client | | **Usage** | Single-use — each presignature can only be used for one signing operation | ## Use Cases * **Low-latency signing** — Pre-compute during idle time so that user-facing sign requests complete faster. * **Batch preparation** — Generate multiple presignatures in advance for a batch of upcoming transactions. * **Time-sensitive transactions** — Prepare presignatures before a deadline so signing is fast when needed. ## Compatible Endpoints Presignatures can be used with the following Enclave MPC API endpoints: * **Sign** (`POST /v1/sign`) — RPC-based signing (e.g., `eth_sendTransaction`) * **Raw Sign** (`POST /v1/raw/sign/SECP256K1`) — Sign a hex digest string * **Send Assets** (`POST /v1/assets/send`) — High-level token transfer Presignatures currently only support the `SECP256K1` curve. ED25519 (Solana) support is coming soon. ## Best Practices * **For client-stored presignatures, store the `id` and `expiresAt`** alongside the `data` value. While the `id` is not required for signing, it is useful for bookkeeping and may be needed when communicating with Portal support. * **For Portal-managed presignatures, store the `id` and `expiresAt`** so you can pass the `id` as `presignatureId` and clean up expired presignatures. * **Discard expired presignatures** — set up a regular process to check `expiresAt` timestamps and clean up expired entries so you stay within the 100 active presignature limit. ## SDK behavior The SDK manages presignatures automatically when you enable the **`usePresignatures`** feature flag. With the flag enabled, the SDK generates presignatures in the background and uses them during signing (e.g. `portal.request()` or send-asset flows) to improve latency ## Next Steps Ready to use presignatures? Choose your platform: * [Presignatures with Enclave MPC API](/apis/enclave-mpc/guide/presignatures) — For server-side or custom integrations. * **React Native** — Enable the [`usePresignatures`](/sdks/react-native/guide/feature-flags#usepresignatures) feature flag when initializing Portal * **Web** — Enable the [`usePresignatures`](/sdks/web/guide/feature-flags#usepresignatures) feature flag when initializing Portal # Rate Limits Source: https://docs.portalhq.io/resources/rate-limits This page outlines the rate limits for API requests to `portalhq.io`. These limits are applied in order of priority, meaning more specific routes take precedence over general ones. ## Rate Limits Overview
NameRoutesLimit (Requests per minute)
Clients API`api.portalhq.io/api/:version/clients/*`200
Custodian API`api.portalhq.io/api/:version/custodian/*`200
Enclave MPC API`mpc.portalhq.io/*`50
All Services`*`200
If you have any questions or need further clarification, please reach out to our support team. # RPC endpoints Source: https://docs.portalhq.io/resources/rpc-endpoints Use Portal's managed RPC gateway to send JSON-RPC requests to any supported chain without running your own node or provider. ## Portal's RPC gateway Portal exposes a managed JSON-RPC gateway so you can read chain state and broadcast transactions without provisioning your own RPC provider. Supported EVM and Solana networks are reachable at a single, predictable URL: ``` POST https://api.portalhq.io/rpc/v1/{namespace}/{reference} ``` `{namespace}` and `{reference}` are the two halves of the chain's [CAIP-2 chain ID](./chain-id-formatting) (`namespace:reference`). To build the endpoint, take the CAIP-2 ID and replace the `:` with `/`. For example, the CAIP-2 ID for Monad mainnet is `eip155:143`, so its RPC endpoint is `https://api.portalhq.io/rpc/v1/eip155/143`. The gateway covers the `eip155` (EVM) and `solana` namespaces — not every chain in [Blockchain support](./blockchain-support) has a gateway endpoint. Authenticate with your **Client API Key** or **Client Session Token**; see [Authentication & API keys](./authentication-and-api-keys). ## Common endpoints Here are the most commonly used endpoints, listed explicitly for quick reference: | Network | CAIP-2 chain ID | RPC endpoint | | ---------------- | ----------------------------------------- | ------------------------------------------------------------------------ | | Monad Mainnet | `eip155:143` | `https://api.portalhq.io/rpc/v1/eip155/143` | | Monad Testnet | `eip155:10143` | `https://api.portalhq.io/rpc/v1/eip155/10143` | | Ethereum Mainnet | `eip155:1` | `https://api.portalhq.io/rpc/v1/eip155/1` | | Solana Mainnet | `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` | `https://api.portalhq.io/rpc/v1/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` | ## The pattern for supported chains Gateway-supported chains follow the same `…/rpc/v1/{namespace}/{reference}` shape — take the CAIP-2 chain ID and swap the `:` for a `/`. A few examples: | Network | CAIP-2 chain ID | RPC endpoint | | ---------------- | ----------------------------------------- | ------------------------------------------------------------------------ | | Ethereum Mainnet | `eip155:1` | `https://api.portalhq.io/rpc/v1/eip155/1` | | Ethereum Sepolia | `eip155:11155111` | `https://api.portalhq.io/rpc/v1/eip155/11155111` | | Base Mainnet | `eip155:8453` | `https://api.portalhq.io/rpc/v1/eip155/8453` | | Polygon Mainnet | `eip155:137` | `https://api.portalhq.io/rpc/v1/eip155/137` | | Arbitrum Mainnet | `eip155:42161` | `https://api.portalhq.io/rpc/v1/eip155/42161` | | Optimism Mainnet | `eip155:10` | `https://api.portalhq.io/rpc/v1/eip155/10` | | Solana Mainnet | `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` | `https://api.portalhq.io/rpc/v1/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` | | Solana Devnet | `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` | `https://api.portalhq.io/rpc/v1/solana/EtWTRABZaYq6iMfeYKouRu166VU2xqa1` | For the full list of supported chains and their CAIP-2 IDs, see [Blockchain support](./blockchain-support). For how CAIP-2 IDs are formed, see [Chain ID formatting](./chain-id-formatting). ## Official public RPC endpoints If you would rather connect directly to a chain's own public RPC instead of Portal's gateway, use the official endpoints below. | Network | CAIP-2 chain ID | Official public RPC URL | | ---------------- | ----------------------------------------- | ------------------------------------- | | Monad Mainnet | `eip155:143` | `https://rpc1.monad.xyz` | | Monad Testnet | `eip155:10143` | `https://testnet-rpc.monad.xyz` | | Base Mainnet | `eip155:8453` | `https://mainnet.base.org` | | Optimism Mainnet | `eip155:10` | `https://mainnet.optimism.io` | | Arbitrum One | `eip155:42161` | `https://arb1.arbitrum.io/rpc` | | Polygon Mainnet | `eip155:137` | `https://polygon-rpc.com` | | Solana Mainnet | `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` | `https://api.mainnet-beta.solana.com` | | Solana Devnet | `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` | `https://api.devnet.solana.com` | For chains not listed here — including Ethereum mainnet and testnets, which have no single canonical public endpoint — find an official or community public RPC via [chainlist.org](https://chainlist.org). Public RPC endpoints are free but heavily rate-limited and are not intended for production workloads. For production, use Portal's RPC gateway above or a dedicated RPC provider. ## Making a request The gateway accepts standard JSON-RPC `POST` requests. Authenticate with your Client API Key: ```bash theme={null} curl --request POST \ --url https://api.portalhq.io/rpc/v1/eip155/143 \ --header 'Authorization: Bearer YOUR_CLIENT_API_KEY' \ --header 'Content-Type: application/json' \ --data '{ "jsonrpc": "2.0", "id": 1, "method": "eth_blockNumber", "params": [] }' ``` ## Using it as your `rpcUrl` Anywhere Portal accepts an `rpcUrl` — for example in the SDKs or in the Enclave MPC API send and sign requests — you can pass the gateway URL for the target chain so Portal uses its managed RPC: ```json theme={null} { "chain": "eip155:143", "rpcUrl": "https://api.portalhq.io/rpc/v1/eip155/143", "to": "0x1234...", "token": "NATIVE", "amount": "0.01" } ``` If you would rather use a dedicated provider, pass your own RPC URL here instead. On iOS, Android, and Flutter you don't need to pass an `rpcUrl` for the common chains Portal supports — these SDKs default to Portal's RPC gateway automatically. Pass an `rpcUrl` only to use a different provider or a chain outside the default set. On Web and React Native, configure your RPC endpoints explicitly. ## Related * [Blockchain support](./blockchain-support) — the full list of supported chains and CAIP-2 IDs. * [Chain ID formatting](./chain-id-formatting) — how `namespace:reference` CAIP-2 IDs are formed. * [Authentication & API keys](./authentication-and-api-keys) — the Client API Key and Client Session Token used to authenticate. # Security Source: https://docs.portalhq.io/resources/security Security at Portal has been built in from day one. ### SOC 2 Type II **Portal is SOC 2 Type II certified**. You can read about Portal’s SOC 2 Type II certification renewal [here](https://www.portalhq.io/post/portals-soc-2-type-2-certification-renewed). ### Internal Security * All data is **encrypted at rest** and all data transmitted two and from Portal **protected by TLS**. * Sensitive data is further encrypted at the application layer **backed by KMS**. * Portal performs **quarterly pen tests** in addition to internal **threat modeling**. ### Cryptography All cryptographic functions required for secure MPC are built on widely reviewed open source libraries and have been formally reviewed by external security teams. Portal SDK's leverage the [CGGMP MPC protocol](https://eprint.iacr.org/2021/060.pdf) for ECDSA signatures. Check out our blog post [here](https://www.portalhq.io/post/portal-security-principles-and-practices) for more details regarding Portal's security principles and practices. # Self-Managed Backups Source: https://docs.portalhq.io/resources/self-managed-backups Store and manage backup shares in your own infrastructure instead of using Portal-Managed Backups. ## Backup + Recovery Webhooks By default, all customers use Portal-Managed Backups - a fully managed backup solution where Portal securely handles encrypting and storing your Portal wallets' backup shares. However, you can opt for "Self-Managed Backups" if you need to handle backup and recovery through your own infrastructure. This guide covers implementing Self-Managed Backup using webhooks. If you're using the default Portal-Managed Backups, you do not need to implement these webhooks. ### Why do I need webhooks for Self-Managed Backups? When using Self-Managed Backups, the raw custodian backup share is stored in your database rather than Portal's infrastructure. Here's how it works: During the backup process, Portal generates the custodian backup share on its servers and then sends it to your API via a webhook endpoint for you to store. After the webhook request completes, the custodian backup share is deleted from the Portal servers. Portal never stores the custodian backup shares in its database — it is only ever used in memory during the duration of the backup and recover processes. The raw **custodian backup share** is stored in your database. Then, during the recovery process, Portal will request the custodian backup shares for the Portal client from your API via the `POST` `/backup/fetch` webhook endpoint. After the wallet successfully recovers, a new set of **signing** **shares** are generated, which are then stored on the Portal client's device. ### Your Database Before we dive into the webhook endpoints' implementations, we should go over your database schema. Multi-backup requires storing both custodian and user backup shares by their respective backup method in your own database. We recommend the following database model structure: ```prisma theme={null} // Encrypted backup share received from portal.backup(). // Encrypted backup share retrieved from your API to be used in portal.recover(). model UserBackupShare { backupMethod String @default("UNKNOWN") // (String) The method used for the backup. Defaults to "UNKNOWN" if not specified. createdAt DateTime @default(now()) // (DateTime) The timestamp when the backup share was created. id String @id @default(cuid()) // (String) A unique identifier for each backup share cipherText String // (String) The encrypted backup share data. userId String // (Int) The identifier of the user to whom the backup share belongs. user User @relation(fields: [userId], references: [id]) // (User) A relation field that links to the corresponding user in the User model. @@unique([userId, backupMethod]) // A unique constraint ensuring that each user has only one backup share per method. } // Raw backup share received from the POST /backup webhook. // Raw backup share retrieved from the POST /backup/fetch webhook. model CustodianBackupShare { backupMethod String @default("UNKNOWN") // (String) The method used for the backup. Defaults to "UNKNOWN" if not specified. createdAt DateTime @default(now()) // (DateTime) The timestamp when the backup share was created. id String @id @default(cuid()) // (String) A unique identifier for each backup share share String // (String) The raw backup share data. userId String // (String) The identifier of the user to whom the backup share belongs. user User @relation(fields: [userId], references: [id]) // (User) A relation field that links to the corresponding user in the User model. @@unique([userId, backupMethod]) // A unique constraint ensuring that each user has only one backup share per method. } // User model, often referred to as "Client" in our documentation. model User { clientApiKey String @unique // (String) The unique Client API Key assigned to the user. clientId String @unique // (String) The unique identifier for the client that Portal uses. id String @id @default(cuid()) // (String) The primary key for the user userBackupShares UserBackupShare[] // (UserBackupShare[]) A list of the user backup shares related to the user. custodianBackupShares CustodianBackupShare[] // (CustodianBackupShare[]) A list of the custodian backup shares related to the user. } ``` We won't go into depth just yet, but it's worth calling out that you can see a couple associations: 1. A user has many raw custodian backup shares *unique by backup method*. 2. A user has many encrypted user backup shares *unique by backup method*. 1. The encryption key is stored differently depending on the backup method used. Perfect, let's dive right into building out the webhook endpoints with this context. ### API Endpoints you need to build There are two webhook endpoints that your API needs to support: `POST` `/backup` for backup and `POST` `/backup/fetch` for recover. ### `POST` `/backup` During the backup process, Portal sends the **custodian backup share** to your API to store. The request will include a `X-Webhook-Secret` header (you can set this in the [Portal Admin Dashboard](https://app.portalhq.io)). The body of this `POST` request will contain three fields: * `backupMethod` - The backup method used to create the backup share. Example values: `"GDRIVE"`, `"GDRIVE-SECP256K1"`, `"GDRIVE-ED25519"`, `"ICLOUD"`, `"PASSKEY"`, `"PASSWORD"`, etc. This should be stored as a generic string (not an enum) by `clientId`. Its value is subject to change in future releases. * `clientId` - The Portal ID of the user. We recommend keeping track of this. * `share` - A JSON stringified version of the custodian backup share. Your webhook server is expected to store the `backupMethod`, `clientId`, and `share` value in a secure database. Even if you are currently focused on a specific blockchain like Solana or Ethereum, it's essential to **store ALL backup shares** sent to your webhook. For example, if you're only using Solana and receive backup shares for both `"GDRIVE-SECP256K1"` and `"GDRIVE-ED25519"`, you need to store both. This ensures that all necessary data is available for wallet recovery, regardless of the blockchain involved. The backup **`share`** is critical to protecting your users' wallet. It is important to treat the `share` value as a sensitive value and handle it accordingly. Do not log the value (or request body), be sure to store the value encrypted at rest, and understand the access control on the database. It should also return a `200` status code. Here is a simple example of how to set this up using Node and Express: ```typescript theme={null} app.post('/webhook/backup', async (req, res) => { const backupMethod = req.body['backupMethod'] const share = req.body['share'] validateShare(share) const clientId = req.body['clientId'] const user = await this.getUserByClientId(clientId) // Remove any existing custodian backup shares for this backup method + user. await db.custodianBackupShare.deleteMany({ where: { backupMethod, userId: user.id, }, }) // Store the custodian backup share. await db.custodianBackupShare.create({ data: { backupMethod, share, userId: user.id, }, }) res.status(204).send() }) ``` When Portal makes a request to your `/backup` webhook, another immediate request is made to `/backup/fetch` right after in order to validate the custodian backup share was stored successfully. The `/backup/fetch` webhook is explained in the "[Handling recovery](../sdks/react-native/guide/recover-a-wallet)" doc. Your webhook must respond within 10 seconds. If your webhook has an error or does not respond in time, the propagated error will look something like this: **`FAILED_WEBHOOK`**: This error occurs when a webhook experiences an error. Unable to send backup share to configured webhook. Check your webhook status in the [Portal Admin Dashboard](https://app.portalhq.io/) to learn more. ### Recovery During the recovery process, Portal requests the **custodian backup share** from your API. Portal will request the existing custodian backup shares for a user with a `POST` request to `[webhookBaseURL]/backup/fetch`. The request will include a `X-Webhook-Secret` header (you can set this in the [Portal Admin Dashboard](https://app.portalhq.io)). The request body of this request will contain one field: * `clientId` - The Portal Id of the user. Your webhook server is expected to fetch the custodian backup shares for the given `clientId`. It should also return a `200` status code along with the `backupShares` in the response body. Here is a simple example of how to set this up using Node and Express: ```typescript theme={null} app.post('/webhook/backup/fetch', async (req: Request, res: Response) => { const { clientId } = req.body // Obtain all custodian backup shares for the client. const custodianBackupShares = await db.custodianBackupShares.findMany({ where: { clientId, }, }) // Ensure to only return back the JSON stringified shares you received from the /backup webhook endpoint. const rawShares = custodianBackupShares.map((custodianBackupShare) => { return custodianBackupShare.share }) res.status(200).send({ backupShares: rawShares }) }) ``` Once we retrieve your custodian backup shares for the client, we will do the heavy lifting to figure out which backup method you're using and select the appropriate custodian backup share to use as a result. So it's as simple as that, just send us all of the custodian backup shares for the client and we'll take care of the rest for this webhook endpoint! :muscle: ### Configuration To configure your webhook go to the [Settings Page](https://app.portalhq.io/settings) of the Portal web app. Navigate to the **Webhooks** section and select the "New +" button. Enter in the base URL for your webhook server and select "Create". A secure, random webhook secret will be automatically generated for you unless you prefer to create your own secret. Once the webhook base URL has been added. You will be able to view the webhook configuration. See the image below describing the different components of the webhook configuration. 1. **Base URL -** This is the URL you've configured. 1. When Portal runs **backup** it will send a request to the `[webhookBaseURL]/backup` route to send your server a custodian backup share to save for a client by backup method. 2. When Portal runs **recover** it will send a request to the `[webhookBaseURL]/backup/fetch` endpoint to request the custodian backup shares for a user from your server. 2. **Webhook Secret** - Click the icon to reveal your webhook secret. 3. **Webhook Endpoint** - Each endpoint that Portal sends requests to has a status indicator letting you know whether the route is working correctly. 4. **Request Explorer** - To view the HTTP requests and responses sent to each route select "View Requests". ### Security To ensure secure transmission of recovery shares via the webhook we follow several security best practices. #### Webhook Secret Including a secret along with each webhook request adds authentication to the requests sent to your webhook server. You should verify that the secret with the request matches the secret configured in Portal. All of our webhook requests are sent with a secret in the `X-Webhook-Secret` header. The secret is securely generated on webhook configuration and can be viewed in the [Settings Page](https://app.portalhq.io/settings) of the web app. #### IP Allowlist Restricting requests on your webhook server to only those from Portal's IP addresses protects against requests from other parties. Configure your webhook server to only accept inbound connections from Portal's IP address. Portal always makes requests from the IP addresses `35.203.150.117`, `104.155.171.139` or `35.185.20.23`. # Single Sign-On (SSO) Source: https://docs.portalhq.io/resources/single-sign-on Configure Single Sign-On (SSO) for your organization in the Portal Admin Dashboard using OIDC or SAML2 protocols to manage authentication and authorization for your members. ## Overview Single Sign-On (SSO) allows organizations to manage authentication and authorization of their members for external applications, such as Portal's Admin Dashboard, using an identity provider (IdP) like Okta, Azure AD, or Google Workspace. With SSO enabled, your organization's members can access the Portal Admin Dashboard using their existing corporate credentials, eliminating the need for separate Portal accounts. This provides a seamless authentication experience while maintaining centralized control over access. If you don't see the Single Sign-On section in your Settings page, reach out to Portal's support team via [Slack](https://join.slack.com/t/portalcommunity/shared_invite/zt-1zlyexf43-A94Z4ugntrgLGs2j5slmeQ) to enable SSO for your workspace. Portal supports two primary web SSO protocols: * **OIDC (OpenID Connect)**: A modern authentication protocol built on OAuth 2.0 * **SAML2 (Security Assertion Markup Language 2.0)**: An XML-based protocol for exchanging authentication and authorization data ## How SSO Works Once SSO is configured for your organization, the authentication flow works as follows: 1. **Member Access**: When a member attempts to access the Admin Dashboard, they are prompted to provide a unique **slug** associated with your organization's SSO configuration. 2. **IdP Redirect**: After entering the slug, the member is redirected to your organization's identity provider (IdP) for authentication. 3. **Authentication**: The member authenticates using their corporate credentials through the IdP. 4. **Dashboard Access**: Upon successful authentication at the IdP, the member is redirected back to the Portal Admin Dashboard and granted access. This process ensures that authentication is handled by your organization's IdP, giving you centralized control over who can access the Portal Admin Dashboard. ## Configuration Steps To begin configuring SSO, first access the Single Sign-On section: 1. Log into your [Portal Admin Dashboard](https://app.portalhq.io). 2. Navigate to the [Settings](https://app.portalhq.io/settings) page. 3. Locate the **"Single Sign-On"** section. Portal Admin Dashboard Settings page showing Single Sign-On in the navigation sidebar Single Sign-On section showing Configure SAML and Configure OIDC buttons, with Status showing Pending Configuration and Slug displayed Choose either OIDC or SAML2 based on your organization's identity provider capabilities. You only need to configure one protocol. ## OIDC Configuration Steps To set up OIDC Single Sign-On: 1. In the Single Sign-On section, click **Configure OIDC**. 2. A modal titled **"Configure OIDC Single Sign-On Connection"** will appear. OIDC configuration modal showing Redirect URL with copy button, and input fields for Client ID, Client Secret, and Issuer URL The modal displays information you'll need to configure in your identity provider, and fields for information you'll need to provide from your identity provider: **Information from Portal (to configure in your IdP):** * **Redirect URL**: The callback URL that your IdP will use to redirect users back to Portal after authentication. **Information from your IdP (to enter in Portal):** * **Client ID**: The OAuth client identifier from your IdP * **Client Secret**: The OAuth client secret from your IdP * **Issuer URL**: The OIDC issuer URL from your IdP (typically in the format `https://your-idp-domain.com`) ### Configure Your Identity Provider 1. Log into your identity provider's admin console. 2. Create a new **OIDC Application** and choose **Web Application** for Application Type. 3. Configure the following in your IdP: * **Redirect URI / Callback URL**: Paste the Redirect URL you copied from Portal * Note the **Client ID** and **Client Secret** that your IdP generates * Note your IdP's **Issuer URL** 4. From your IdP, copy the following: * **Client ID**: The OAuth client identifier from your IdP * **Client Secret**: The OAuth client secret from your IdP * **Issuer URL**: The OIDC issuer URL from your IdP 5. Return to the Portal modal and enter the **Client ID**, **Client Secret**, and **Issuer URL**. 6. Click **"Configure"** to save your OIDC SSO settings. Ensure that the Redirect URL in your IdP exactly matches the one provided by Portal. Any mismatch will prevent successful authentication. ## SAML2 Configuration Steps To set up SAML2 Single Sign-On: 1. In the Single Sign-On section, click **Configure SAML2**. 2. A modal titled **"Configure SAML Single Sign-On Connection"** will appear. SAML configuration modal showing SSO/ACS URL, Audience URI/Entity Id, Name ID Format, and input fields for IdP SSO URL, IdP Entity Id, and x509 Certificate The modal displays information you'll need to configure in your identity provider, and fields for information you'll need to provide from your identity provider: **Information from Portal (to configure in your IdP):** * **Single Sign-On (SSO) / ACS URL**: The Assertion Consumer Service URL where your IdP should send SAML responses. * **Audience URI / Entity Id**: The Entity ID that identifies Portal as the Service Provider. This is typically the same URL as the ACS URL. * **Name ID Format**: The format for the user identifier. Portal uses **"Email Address"** as the Name ID format. **Information from your IdP (to enter in Portal):** * **IdP SSO URL**: The Single Sign-On URL from your IdP where users will be redirected for authentication * **IdP Entity Id**: The Entity ID that identifies your identity provider * **x509 Certificate**: The X.509 certificate from your IdP used to verify SAML assertions ### Configure Your Identity Provider 1. Log into your identity provider's admin console. 2. Create a new **SAML Application**. 3. Configure the following in your IdP: * **Single sign-on URL / ACS URL**: Paste the SSO / ACS URL you copied from Portal * **Audience URI / SP Entity ID**: Paste the Audience URI / Entity Id you copied from Portal * **Name ID format**: Set to **"Email Address"** (or "Unspecified" if your IdP supports it and will send email addresses) * **Recipient URL and Destination URL**: Typically the same as the Single sign-on URL (some IdPs have a checkbox to use the same value) 4. From your IdP, copy the following: * **IdP SSO URL**: The Single Sign-On URL from your IdP * **IdP Entity Id**: The Entity ID from your IdP * **x509 Certificate**: The X.509 certificate (usually found in the SAML settings or metadata) 5. Return to the Portal modal and enter the **IdP SSO URL**, **IdP Entity Id**, and **x509 Certificate**. 6. Click **"Configure"** to save your SAML2 SSO settings. Ensure that the ACS URL and Entity ID in your IdP exactly match the values provided by Portal. Any mismatch will prevent successful authentication. The x509 Certificate should be copied in its entirety, including the `-----BEGIN CERTIFICATE-----` and `-----END CERTIFICATE-----` lines if they are present in your IdP's certificate display. ## Understanding Your SSO Configuration After configuring SSO, the Single Sign-On section will display: * **Status**: Shows whether SSO is **Enabled** or **Disabled** * **Slug**: A unique identifier for your SSO connection (e.g., `david-example-okta-sso`). This is the slug that members will use to access the dashboard via SSO. * **Reconfigure Button**: Allows you to update your SSO configuration at any time The slug is automatically generated based on your configuration. Members will need to know this slug to access the dashboard via SSO. ## Common Identity Provider Setup Examples ### Okta For **OIDC with Okta**: 1. In Okta Admin Console, go to **Applications** > **Applications** > **Create App Integration**. 2. Select **OIDC - OpenID Connect** as the sign-in method. 3. Choose **Web Application** as the application type. 4. Configure: * **Sign-in redirect URIs**: Add the Redirect URL from Portal * **Sign-out redirect URIs**: (Optional) Add a sign-out URL if needed 5. After creating the app, note the **Client ID** and **Client Secret**. 6. The **Issuer URL** is typically: `https://your-domain.okta.com` or `https://your-domain.okta.com/oauth2/default` For **SAML2 with Okta**: 1. In Okta Admin Console, go to **Applications** > **Applications** > **Create App Integration**. 2. Select **SAML 2.0** as the sign-in method. 3. In the **"2 Configure SAML"** step, configure the following: * **Single sign-on URL**: Paste the SSO / ACS URL from Portal * Check the box **"Use this for Recipient URL and Destination URL"** (if available) * **Audience URI (SP Entity ID)**: Paste the Audience URI / Entity Id from Portal * **Name ID format**: Select **"Unspecified"** (Okta will send email addresses when configured) * **Application username**: Typically set to **"Okta username"** or **"Email"** 4. After saving, go to the **"Sign On"** tab to find: * **IdP SSO URL**: Copy the "Identity Provider Single Sign-On URL" * **IdP Entity Id**: Copy the "Identity Provider Issuer" (Entity ID) * **x509 Certificate**: Copy the certificate from the "X.509 Certificate" section 5. Enter these values in the Portal SAML configuration modal. ### Azure AD (Microsoft Entra ID) For **OIDC with Azure AD**: 1. In Azure Portal, go to **Azure Active Directory** > **App registrations** > **New registration**. 2. Configure: * **Redirect URI**: Add the Redirect URL from Portal (select "Web" platform) 3. After registration, note the **Application (client) ID**. 4. Go to **Certificates & secrets** to create a new client secret. 5. The **Issuer URL** format is: `https://login.microsoftonline.com/{tenant-id}/v2.0` For **SAML2 with Azure AD**: 1. In Azure Portal, go to **Enterprise applications** > **New application**. 2. Choose **Non-gallery application** or integrate a SAML application. 3. Configure the SAML settings with the ACS URL and Entity ID provided by Portal. ### Google Workspace For **OIDC with Google Workspace**: 1. Go to [Google Cloud Console](https://console.cloud.google.com/). 2. Navigate to **APIs & Services** > **Credentials** > **Create Credentials** > **OAuth client ID**. 3. Configure: * **Application type**: Web application * **Authorized redirect URIs**: Add the Redirect URL from Portal 4. Note the **Client ID** and **Client Secret**. 5. The **Issuer URL** is: `https://accounts.google.com` ## Troubleshooting ### Common Issues **Issue: Redirect URL mismatch** * **Solution**: Ensure the Redirect URL in your IdP exactly matches the one provided by Portal. Check for trailing slashes, HTTP vs HTTPS, and any extra characters. **Issue: Invalid Client ID or Client Secret** * **Solution**: Verify that you've copied the Client ID and Client Secret correctly from your IdP. Some IdPs require you to view the secret immediately after creation. **Issue: Invalid Issuer URL** * **Solution**: Confirm the Issuer URL format with your IdP documentation. Some IdPs use different issuer URLs for different environments (e.g., sandbox vs. production). **Issue: Members cannot access dashboard after SSO setup** * **Solution**: * Verify that SSO status shows as "Enabled" * Confirm members are using the correct slug * Check that the IdP configuration is complete and active * Ensure members have the necessary permissions in your IdP **Issue: SAML2 configuration errors** * **Solution**: * Verify the X.509 certificate is valid and not expired * Ensure the Entity ID matches between Portal and your IdP * Check that the ACS URL is correctly configured in your IdP * Review SAML response logs in your IdP for specific error messages If you encounter issues not covered here, reach out to Portal's support team via [Slack](https://join.slack.com/t/portalcommunity/shared_invite/zt-1zlyexf43-A94Z4ugntrgLGs2j5slmeQ) for assistance. ## Support Congratulations! 🎉 You've successfully configured Single Sign-On for your organization. Your members can now access the Portal Admin Dashboard using their corporate credentials through your identity provider. If you have any questions or need assistance with SSO configuration, please reach out to our support team via [Slack](https://join.slack.com/t/portalcommunity/shared_invite/zt-1zlyexf43-A94Z4ugntrgLGs2j5slmeQ). # Testnet faucets Source: https://docs.portalhq.io/resources/testnet-faucets Easily get free tokens to test with in your applications. ### What are faucets? Faucets provide free tokens for testing purposes on testnet blockchain networks. These tokens have no monetary value and are just used to experiment with while building out your applications. #### Faucets * [Monad Faucets](https://faucet.monad.xyz/) * [Alchemy Faucets (Sepolia, Base, Polygon, Arbitrum, Optimism, etc.)](https://www.alchemy.com/faucets) * [Chainlink Faucets (Sepolia, Base, Polygon, Arbitrum, Optimism, etc.)](https://faucets.chain.link/) * [Circle Faucets (USDC)](https://faucet.circle.com/) * [Paxos Faucets (PYUSD)](https://faucet.paxos.com/) * [Solana Faucets](https://faucet.solana.com/) * Bitcoin Faucets * [https://bitcoinfaucet.uo1.net/](https://bitcoinfaucet.uo1.net/) * [https://coinfaucet.eu/en/btc-testnet/](https://coinfaucet.eu/en/btc-testnet/) ### Using a Faucet 1. **Choose Your Network**: Confirm the chain you're testing on (e.g., Sepolia, Polygon, Base, etc.). 2. **Get a Wallet**: [Create a wallet using Portal](../sdks/web/guide/create-a-wallet) and ensure you're using the desired chain. 3. **Visit the Faucet**: Click the appropriate link above and follow the instructions (usually just submitting your wallet address). 4. **Receive Tokens**: Tokens will typically arrive within a few seconds to minutes. Use them to test in your application. And that's it! 🎉 Please reach out to our team if you run into any issues. # Wallet lifecycle Source: https://docs.portalhq.io/resources/wallet-lifecycle Portal's wallet model — one wallet per client — and how its signing and backup shares change state. ## One client, one wallet In Portal, a **client** is the identity associated with a wallet's MPC key shares, and each client has **exactly one wallet**. One wallet covers every supported chain: a single `generate` produces key shares on two curves — `SECP256K1` (EVM, Bitcoin, Tron) and `ED25519` (Solana, Stellar) — and each chain's address is derived from the curve it uses. Read them from the SDKs via `portal.addresses`, or from the API via the [Get a client](/api-reference/clients/get-a-client) endpoint. You cannot generate a second wallet for a client that already has one. If you call `generate` for a client that already has a wallet, Portal rejects it with HTTP `400 Wallet Already Exists` rather than creating new shares. Treat this as expected — read the client's existing addresses via `portal.addresses` instead of regenerating. ### Supporting multiple wallets per user One client is one wallet — but your end users aren't limited to one wallet. To give a user more than one wallet (for example a **treasury wallet** and an **operational wallet**), create **one client per wallet** and store which clients belong to that user in your own system. Each client is independent: its own shares, its own backup and recovery, and its own addresses. ```mermaid theme={null} flowchart LR U["Your end user"] --> C1["Portal Client A
(treasury)"] U --> C2["Portal Client B
(operational)"] C1 --> W1["Wallet A"] C2 --> W2["Wallet B"] ``` To add a wallet for an existing user, create another client with [Create a client](/api-reference/clients/create-a-client) and run the normal generate flow on the new client. Don't call generate again on a client that already has a wallet. ## MPC operations There are 4 core MPC operations that can take place with your wallet: * Generate * Backup * Recover * Sign The first 3 (Generate, Backup, Recover) are all *state changing* operations that affect either the set of **signing shares** or **backup shares**. Let's explore the details of each one. ### **Generate** Creates new signing shares from randomly generated values. This only affects **signing shares.** ### Steps When you call `portal.createWallet()` two steps happen under the hood. 1. **Create Signing Shares -** After this completes the custodian signing share will have been successfully stored in Portal's database and the client signing share is stored on the Portal client's device. This updates the signing share status to `STORED_DATABASE`. 2. **Store Client Signing Share to Keychain -** After the WebSocket successfully closes the client *signing* share is written to the device keychain and the SDK notifies the server that it succeeded. This updates the *signing* shares status to `STORED_CLIENT`. ### **Backup** Creates new backup shares using the signing shares. This only affects **backup shares.** ### Steps These are the steps of what happens when you call `portal.backupWallet()`. 1. **Create Backup Shares -** After this completes the custodian backup share is saved in Portal's backend (if you are using [Self-Managed Backups](./self-managed-backups), the custodian backup share is then saved in your backend via the configured webhook). This updates the backup share pair's status to `STORED_CUSTODIAN_BACKUP_SHARE`. 2. **Encrypt Client Backup Share** - The client backup share is encrypted on the client-side and the encryption key is uploaded to the client's cloud storage. 3. **Store Client Backup Share** - By default, Portal will store the encrypted client backup share for you. (If you are using Self-Managed Backups, you are required to store the encrypted client backup share and, if successful, call `portal.api.storedClientBackupShare()` to notify our backend that the client backup share was successfully stored.) This updates the backup shares status to `STORED_CLIENT_BACKUP_SHARE`. If backup fails you are expected to handle the error (see the [Error Reference](./error-codes)) and re-run backup after handling it. Recover cannot be run until Backup succeeds. ### **Recover** Creates new signing shares using the backup shares. This only affects **signing shares.** ### Steps These are the steps of what happens when you call `portal.recoverWallet()`. 1. **Create Signing Shares -** After this completes the custodian signing share will have been successfully updated in Portal's database and the client signing share is available on the client's device. 2. **Store Client Signing Share to Keychain -** After the WebSocket successfully closes the client signing share is written to the client's device's keychain. The SDK notifies the server that it succeeded. This updates the signing shares status to `STORED_CLIENT`. If recovery fails you are expected to handle the error (see the [Error Reference](./error-codes)) and re-run recovery after handling it. Signing functionality will not work until a successful recover on a device that does not have a signing share in its keychain. # WalletConnect metadata Source: https://docs.portalhq.io/resources/walletconnect-metadata Personalize the pairing information displayed on third-party dApps via WalletConnect from the Portal Admin Dashboard. When users connect to a dApp using WalletConnect, the dApp may showcase your organization's details, such as the **name**, **description**, **website**, and **logo**. Personalize this information following the below steps. ### Customize WalletConnect Metadata #### **Go to the Portal Admin Dashboard** * Go to the [Settings page](https://app.portalhq.io/settings) of the Portal Admin Dashboard. * Scroll until you find the **WalletConnect Metadata** section. #### **Modify Metadata** * If metadata for your Portal environment is unset, click **New**. * If it's already set, click **Edit** to make updates. * Personalize the metadata and click either **Create** or **Update** in the modal. After you register your dApp with WalletConnect Cloud, you will be assigned a **project ID**. You can find this ID in the [WalletConnect Cloud dashboard](https://cloud.walletconnect.com/app). ### **Test for Accuracy** * Initiate a connection to a dApp using WalletConnect. * Verify that dApps show the updated metadata correctly. * If you notice any discrepancies, return to the [Portal Admin Dashboard](https://app.portalhq.io) to make necessary adjustments. If you don't provide custom metadata for WalletConnect, the default details will be from the Portal's organization information. Congratulations! dApps connected via WalletConnect now show personalized pairing details to your users. Well done! 🎉 # Add custom signature hooks Source: https://docs.portalhq.io/sdks/android/guide/add-custom-signature-hooks This guide will help you configure custom signature hooks for requests to the Portal Provider to ensure your business logic is properly implemented into Portal's signing flow. ## What are custom signature hooks? **Custom signature hooks** allow companies to trigger approval business logic based on parameters including transaction value, transaction volume, types of signature, and more. The business logic can be anything that helps the company and/or end user make good decisions during the signing request process. A couple examples are auto-approvals (eg, auto-approving transactions below a certain value or volume) and two-factor authentication (eg, requiring approval for transactions above a certain limit). Another option are security checks, either internal or external, to prevent malicious activity. ## Why use custom signature hooks? There are a few common reasons developers use custom signature hooks. For example: * **Approving transactions** - Most developers will prompt a user for approval before signing a message from a dApp or Wallet Connect * **Adding additional authentication** - Developers can add extra authentication steps such as MFA to riskier transactions * **Checking transaction sizes** - Developers can perform checks on transaction sizes to trigger specific authentication steps Custom signature hooks can be passive, invisible to the user, or active, designed into the frontend UX with which customers engage. A **passive** flow will generally be used to facilitate company goals, such as ensuring a user isn’t on a deny list. As such, the company can create a custom security check that is activated when a customer signs a transaction without them knowing. Transactions will continue as normal unless the user is found to be denied use of the app, at which time it will be rejected.\ \ An **active** flow is visible to the user and can be used to offer a superior user experience. For example, companies can trigger MFA for transactions above a certain size, an example we will dive into at the end of this guide. ## How do custom signature hooks work? The Portal approval flow is driven by a series of event hooks that take place throughout the lifecycle of a Provider requests. These hooks are as follows: * `portal_signingRequested` - Emitted by the Provider when a new signing request is received by the Provider * `portal_signingApproved` - Emitted by you when signing a given request is approved * `portal_signingRejected` - Emitted by you when signing a given request is rejected ## Designing your flow The expectation of this flow is that your code binds to the `portal_signingRequested` event using the following pattern: ```kotlin theme={null} portal.provider.on("portal_signingRequested") { data: Any? -> val request = data as EthSigningRequest // On approval portal.provider.emit("portal_signingApproved", request) // On rejection portal.provider.emit("portal_signingRejected", request) }) ``` As demonstrated by this example, you can implement any business logic you feel is required to make good decisions about what signing requests to process. You can prompt your user for approval, you can make requests to your backend to run some code remotely, etc. The only restriction of this flow is that when you have determined whether a signing request should be approved or rejected, you should emit either the `portal_signingRejected` or `portal_signingApproved` event. *NOTE: When emitting these events, the Provider uses the `method` and `params` to determine if we are indeed approving or rejecting the correct request, so it is vital that you pass the request data to your `emit()` calls.* ### A more real-world example: MFA for large transactions A common use case is to trigger additional approval from users for large transactions. The implementation of this using custom signature hooks might look something like this: ```kotlin theme={null} portal.provider.on("portal_signingRequested") { data: Any? -> val request = data as EthSigningRequest // Check the transaction size val isLargeTransaction = checkIfLargeTransaction(request) // Request MFA approval from user if large transaction val isMfaApproved = false if (isLargeTransaction) { isMfaApproved = getMfaApproval() } // Approve transaction request if MFA was successful if (isMfaApproved) { portal.provider.emit("portal_signingApproved", request) } else { portal.provider.emit("portal_signingRejected", request) } }) ``` The outcome would look like something like this: ![](https://lh4.googleusercontent.com/IUz10Pt88VpjlIwlhswdWv9Uns9Aui_adLXQGFOufIitcLENyWELQcwAjR-uhoFIT5EsneS0ZW7vJ8khMr_2Vtn-2reeudbwmRMsTmRRW3S7yeMVI1UkoAZ5zcWSdKn9hCSrYB0271KH9hlLIB3ePfc) Now that you've implemented your custom signature hooks, let's dive into how to handle progress callbacks for your `portal.provider.on("portal_signingRequested")` function. # Back up a wallet Source: https://docs.portalhq.io/sdks/android/guide/back-up-a-wallet This guide will walk you through how to create a backup of a Portal client's wallet. ## Portal-Managed Backups Portal lets you securely back up your users' MPC wallets so they can recover their wallets even if their device is lost or damaged. By default, Portal encrypts and stores both backup shares ("Portal-Managed Backups"): 1. The **client backup share** is encrypted on the user's device, with the encryption key stored using their chosen backup method (Google Drive, Password, Passkey, or Firebase Auth). The encrypted share is then stored by Portal. 2. The **custodian backup share** is encrypted and stored by Portal, with the encryption key stored in our KMS infrastructure. By default, Portal manages storing both the encrypted client backup share and the custodian backup share for you. If you prefer to store and manage the backup shares in your own infrastructure instead of using Portal-Managed Backups, see our [Self-Managed Backups](../../../resources/self-managed-backups) guide. Both the client backup share and the custodian backup share are necessary to recover a Portal wallet. ## Threading Portal-managed flows run their blocking work on a background dispatcher internally: `portal.backupWallet(...)` and `portal.recoverWallet(...)` can be called directly from a UI scope: ```kotlin theme={null} viewModelScope.launch { portal.backupWallet(backupMethod = BackupMethods.Gdrive) { status -> // (Optional) Get status updates on the backup operation } } ``` If you use a storage adapter directly (via `portal.getBackupStorage(...)`), fetching the Google access token is main-safe, but the adapter's Drive read, write, and delete calls perform blocking network I/O on the calling dispatcher. Dispatch that work to a background dispatcher yourself: ```kotlin theme={null} viewModelScope.launch(Dispatchers.IO) { val storage = portal.getBackupStorage(BackupMethods.Gdrive) // read / write / delete calls } ``` Google Drive backup signs the user in and may show a consent screen, which requires a started Activity. Run backup and recovery from a foreground flow — a background job (for example WorkManager) will fail because there is no Activity to present the Google sign-in UI. This applies whenever `signOutAfterUse` is `true`, since each operation then re-authenticates. ## Backup Methods You can choose one or more backup methods for storing the encryption key for the client backup share. ### Passkey + Enclave Backup Allow customers to create a native passkey on their device that is used to authenticate into a secure enclave that holds the encryption key for the user. Customer's passkeys are backed up to the native cloud storage for their device. #### Implementation Requirements 1. Initialize passkey storage as a backup option in the Portal Config Object with the correct origin 2. Configuring the relying party #### Relying Party Configuration You have two options when it comes to relying party configurations. It is important that you understand the implications of this decision. A relying party is a trusted domain that is tied to the public key credentials of your users for their passkey. #### Use Portal as your relying party We offer the option to use `portalhq.io` as your relying party domain. It requires you to add `portalhq.io` as a trusted domain in your manifest file in your android application and share your applications SHA fingerprint and namespace with our team. We will add them to our own digital asset links file and that will allow your app to set `portalhq.io` as the relying party. 1. Add `portalhq.io` as an included domain in your manifest file. 2. Share your applications SHA fingerprint and namespace with the Portal Team. #### Use your own relying party Ensure you have set up your digital asset links url correctly in your app and that you are serving a digital asset links file from the URL you specified in the asset statement in your manifest file. Read more about setting this up from the android docs: [Configuring your Digital Asset Links file](https://developer.android.com/training/sign-in/passkeys#add-support-dal) #### Relying Party Origins Regardless of the relying party decision you make above, you will need to set the relying party origin to: ``` android:apk-key-hash: ``` *read more info* [*here*](https://developer.android.com/training/sign-in/passkeys#verify-origin) *on how to get the SHA256 hash of the apk signing cert.* #### Example Initializing the passkey storage class ```kotlin theme={null} portal.configurePasskeyStorage( relyingPartyId = "portalhq.io", relyingPartyOrigins = listOf("android:apk-key-hash:SHA-256-HASH") ) ``` ```kotlin theme={null} val passkey = PasskeyStorage( YOUR_ACTIVITY, relyingPartyId = "portalhq.io", relyingPartyOrigins = listOf("android:apk-key-hash:SHA-256-HASH") ) ``` Calling backup ```kotlin theme={null} // Get an encrypted client backup share. val backupShare = portal.backupWallet( backupMethod = BackupMethods.Passkey ) { status -> // (Optional) Get status updates on the backup operation } ``` ```kotlin theme={null} // Get an encrypted client backup share. val backupShare = portal.backupWallet( backupMethod = BackupMethods.Passkey ) { status -> // (Optional) Get status updates on the backup operation } // Send the user backup share to your API and store it. exchangeApi.storeCipherText(user.id, backupShare, "PASSKEY") // ✅ Notify Portal that the user backup share was stored! 🙌 portal.api.storedClientBackupShare(true, "PASSKEY") ``` ### Password/PIN Allow customers to create a password/pin. Customers can either remember the password or store it in a password storage manager. #### Implementation Requirements 1. Create a UI for password input. 2. Enforce password requirements. Customer can choose between password, PIN code, passcode, or any other text-based input. 3. If user forgets password there are no additional recovery options. ```kotlin theme={null} val backupConfigs = BackupConfigs(PasswordStorageConfig(password = PASSWORD)) // Get an encrypted client backup share. val backupShare = portal.backupWallet( backupMethod = BackupMethods.Password, backupConfigs = backupConfigs ) { status -> // (Optional) Get status updates on the backup operation } ``` ```kotlin theme={null} val backupConfigs = BackupConfigs(PasswordStorageConfig(password = PASSWORD)) // Get an encrypted client backup share. val backupShare = portal.backupWallet( backupMethod = BackupMethods.Password, backupConfigs = backupConfigs ) { status -> // (Optional) Get status updates on the backup operation } // Send the user backup share to your API and store it. storeCipherText(user.id, backupShare, "PASSWORD") // ✅ Notify Portal that the user backup share was stored! 🙌 portal.api.storedClientBackupShare(true, "PASSWORD") ``` ### Google Drive See the docs on how to [configure GDrive backup](../../../resources/backup-options/gdrive). #### Configuring GDrive Backup We provide the following backup options for GDrive in our latest *V5+* version of the Android Portal SDK. 1. **GDriveBackupOption.CustomFolder(optionalFolderName):** Use this if you would like the key to wallet backup shares to be stored in a custom folder of your choice. This folder will be visible to the user in their GDrive app and they can delete it at any time so this option is considered less safe and we don't recommend it. 2. **GDriveBackupOption.AppDataFolder:** Use this option if you would like the key to the wallet backup shares to be stored in a private [app-specific folder](https://developers.google.com/drive/api/guides/appdata) in GDrive. This folder is not visible to the user and they can not delete the key file unless they go to the GDrive settings and delete the app from the list of the apps connected. This option is safer and is recommended. 3. **GDriveBackupOption.AppDataFolderWithFallback:** Use this option if you are coming from v4 of the SDK and would like to use the *AppDataFolder* option in a backward-compatible way. With this option, new backup keys are stored in the [app-specific folder ](https://developers.google.com/drive/api/guides/appdata)in GDrive but when reading if the key is not found in [app-specific folder](https://developers.google.com/drive/api/guides/appdata) then the SDK also checks in the GDrive overall to see if the key is available in a custom folder. This way the old backup keys are not lost when you upgrade from v4 to v5. Use this option only when you are upgrading from v3, v4 to V5 otherwise use *AppDataFolder* option. ```kotlin theme={null} // New integrations should use AppDataFolder. Use AppDataFolderWithFallback only when upgrading // from v3/v4, so pre-existing backups in the user-visible folder remain recoverable. val backupOption = GDriveBackupOption.AppDataFolder portal.configureGoogleStorage( GDriveConfiguration( clientId = gdriveClientId, // the clientId of the Web Application credential like explained above signOutAfterUse = true, //if you would like to keep Google signed in then set it to false gDriveBackupOption = backupOption // is explained below ) ) ``` ```kotlin theme={null} portal.configureGoogleStorage( clientId = gdriveClientId, // the clientId of the Web Application credential like explained above signOutAfterUse = true // //if you would like to keep Google signed in then set it to false ) ``` #### Google consent scopes per backup option The SDK requests only the Drive scopes the option you choose actually needs, so users see the smallest possible consent screen: | Backup option | Requested scopes | Consent checkboxes | | --------------------------- | ------------------------------------------------------------------------------------------------ | ------------------ | | `AppDataFolder` | `https://www.googleapis.com/auth/drive.appdata` | 1 | | `AppDataFolderWithFallback` | `https://www.googleapis.com/auth/drive.appdata` and `https://www.googleapis.com/auth/drive.file` | 2 | | `CustomFolder` | `https://www.googleapis.com/auth/drive.file` | 1 | `AppDataFolderWithFallback` needs the extra `drive.file` scope because its fallback read looks for older backups in the user-visible folder. Both scopes are in Google's non-sensitive category, so neither requires additional OAuth verification. See the [GDrive setup guide](../../../resources/backup-options/gdrive) for configuring these scopes on your OAuth consent screen. If you do not pass in a backup option into `portal.backupWallet()` we will default to `GDRIVE` backup. ### Firebase Auth Backup Allow customers to use their existing Firebase Authentication to authenticate into a secure enclave that holds the encryption key for the user. The Portal SDK leverages Firebase ID tokens to securely store and retrieve encryption keys from the secure enclave. This is ideal if your app already uses Firebase Auth — no additional authentication method is required from your users. See the [Firebase Auth Backup setup guide](../../../resources/backup-options/firebase-byo-auth) for prerequisites and Firebase project configuration. #### Configure Firebase storage After initializing your Portal instance, call `configureFirebaseStorage` with a `getToken` lambda that returns a fresh Firebase ID token: ```kotlin theme={null} import io.portalhq.android.Portal import com.google.firebase.auth.FirebaseAuth import kotlinx.coroutines.tasks.await // Configure Firebase as a backup method portal.configureFirebaseStorage( getToken = { FirebaseAuth.getInstance().currentUser ?.getIdToken(true) ?.await() ?.token } ) ``` The user must be signed in to Firebase before performing any backup or recovery operations. If no Firebase user is signed in, the `getToken` lambda returns `null` and the operation will fail. ```kotlin theme={null} import io.portalhq.android.mpc.data.BackupMethods // Ensure user is signed in to Firebase, then run backup. val backupShare = portal.backupWallet( backupMethod = BackupMethods.Firebase ) { status -> // (Optional) Get status updates on the backup operation } ``` ```kotlin theme={null} import io.portalhq.android.mpc.data.BackupMethods // Ensure user is signed in to Firebase, then run backup. val (cipherText, storageCallback) = portal.backupWallet( backupMethod = BackupMethods.Firebase ) { status -> // (Optional) Get status updates on the backup operation } // Send the user backup share to your API and store it. exchangeApi.storeCipherText(user.id, cipherText, "FIREBASE") // Confirm storage and finalize the backup storageCallback() ``` # Enhance your wallets' security with Blockaid Source: https://docs.portalhq.io/sdks/android/guide/blockaid Learn how to integrate real-time transaction, address, token, and URL security scanning using Portal's Android SDK with Blockaid. Portal's Android SDK integrates with Blockaid to provide real-time security scanning for transactions, addresses, tokens, and URLs. This integration helps detect malicious activity, phishing attempts, scam tokens, and suspicious interactions before users sign or submit transactions. ## Overview The Blockaid integration enables you to: * **Scan transactions** before signing or submission (EVM, Solana, Tron) * **Scan addresses** for known malicious actors or compromised contracts * **Validate tokens** to detect scams or security risks * **Verify URLs** to prevent phishing attacks ## Prerequisites Before using Blockaid security scanning, ensure you have: * A properly initialized Portal client * Blockaid integration enabled in your Portal Dashboard ## Scanning EVM Transactions Use `scanEVMTx` to analyze Ethereum transactions before signing or broadcasting them. This method scans EVM transactions for malicious contract interactions, risky approvals, phishing attempts, and other on-chain security threats. ```kotlin theme={null} lifecycleScope.launch { try { val transactionData = BlockaidScanEVMTransactionData( from = "0x7C01728004d3F2370C1BBC36a4Ad680fE6FE8729", to = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", data = "0x095ea7b3...", value = "0x0", gas = "0x2dc6c0", gasPrice = "0x2dc6c0", nonce = "0x924" ) val request = BlockaidScanEVMRequest( chain = "eip155:1", data = transactionData, options = listOf(BlockaidScanEVMOption.SIMULATION, BlockaidScanEVMOption.VALIDATION) ) val result = portal.security.blockaid.scanEVMTx(request) result.onSuccess { response -> val rawResponse = response.data?.rawResponse println("Chain: ${rawResponse?.chain}") println("Block: ${rawResponse?.block}") rawResponse?.validation?.let { validation -> println("Validation Status: ${validation.status}") println("Result Type: ${validation.resultType}") if (validation.resultType == "Malicious") { println("⚠️ Transaction flagged as MALICIOUS") } else { println("✅ Transaction appears safe") } } }.onFailure { error -> println("Blockaid EVM Scan failed: ${error.message}") } } catch (e: Exception) { println("Error scanning EVM transaction: ${e.message}") } } ``` *** ## Scanning Solana Transactions Use `scanSolanaTx` to analyze Solana transactions before signing. This method detects malicious program invocations, suspicious token movements, and other Solana-specific risks. ```kotlin theme={null} lifecycleScope.launch { try { val transactions = listOf( "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQADCQkVR3SiiKbW0l4c3NBsEn6+zn1o0YsyypPwN0GUhg4K5HK0Tb5GckDLYW+MsovQASt5EZ3bSH3nluRJAE69H61w0BRUDTrpYQcXosUun6/z2BROkRoH/1bL7KLU9s4lCav6k3ZZgV6qeZFwu4pu89WoIGaqUxG4C93XwVmmDy81v8qBaCSP4/UZfdo3q1bud/W+ixymkH8IMe0laQZYrSx4Uhyxec67hYm1VqLV7JTSSYaC/fm7KvWtZOSRzEFT2gMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAT4tlY/P4mFG1wDJl0ektVggHiZf73lTlHBVJ3fK0nDoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANG5fPtlMEOI/eXV7aPDlpcdLUKm8L3VoW6k/oJlCNLaBQYABQLARQQABgAJAwYAAAAAAAAABzwACQoLCwgyMzQMNQ0ONjcPEDg5EgETFBUWOhEXGBkaGxwdHh8gISI7IyQlJicCKCkqKywtAwQuLzAxPBFVCg8JAQcHBgYBAAAAAwHwCgYBExUbBgICAAAPAwIAAAYBISMoEQQBGQAPAwIAAAYBLjA2DwMCAAAGAgIAAAAIBgYICAADAQkGCQUFBgACBQAEBwEAAAgCAAUMAgAAADwaAAAAAAAABgAFBGDMBQAEPPm21Wu6wrmHu23/ZFNIumpp+ADooZjd4JQgvjnBxkUJAgEDBqWqCgmmCAUIBwu1tp+gcP/+Ri3C1tRXUbPdgqo6rVsj/qnqC959wTdC/mRARysLz9HS09TW19jZ2tsC1QYsNrdxMcm5Nq5FXZrM0IXpEA+ApFa+pz/JvkLz0+2vnwuztLW2t7i5uru8vgAPvBv8VUeRwDy9yD1NHIH5Ji6ZA+zrmpHejKOz4MP8SwrKy8zNzs/S09TVAdY=" ) val request = BlockaidScanSolanaRequest( accountAddress = "86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY", transactions = transactions, encoding = BlockaidScanSolanaEncoding.BASE64, chain = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", options = listOf(BlockaidScanSolanaOption.SIMULATION, BlockaidScanSolanaOption.VALIDATION), method = "signAndSendTransaction" ) val result = portal.security.blockaid.scanSolanaTx(request) result.onSuccess { response -> val rawResponse = response.data?.rawResponse println("Status: ${rawResponse?.status ?: "Unknown"}") rawResponse?.result?.validation?.let { validation -> println("Result Type: ${validation.resultType}") if (validation.resultType == "Malicious") { println("⚠️ Transaction flagged as MALICIOUS") } else { println("✅ Transaction appears safe") } } }.onFailure { error -> println("Blockaid Solana Scan failed: ${error.message}") } } catch (e: Exception) { println("Error scanning Solana transaction: ${e.message}") } } ``` *** ## Scanning Tron Transactions Use `scanTronTx` to analyze Tron transactions before signing. This method detects malicious smart contract interactions, suspicious token movements, and other Tron-specific security risks. `scanTronTx` is only supported on Tron mainnet (`tron:mainnet`). Passing a testnet chain identifier such as `tron:shasta` or `tron:nile` returns a failed `Result` (surfaced through the `onFailure` callback) without calling Blockaid. ```kotlin theme={null} lifecycleScope.launch { try { val transactionData = BlockaidScanTronTransactionData( from = "TKhJBwsH7hvegxrUHW4oHT5t1BUDds2UG4", to = "TMacq4TDUw5q8NFBwmbY4RLXvzvG5JTkvi", data = "0xa9059cbb000000000000000000000041...", value = "0x0" ) val request = BlockaidScanTronRequest( chain = "tron:mainnet", accountAddress = "TKhJBwsH7hvegxrUHW4oHT5t1BUDds2UG4", data = transactionData, options = listOf(BlockaidScanTronOption.SIMULATION, BlockaidScanTronOption.VALIDATION) ) val result = portal.security.blockaid.scanTronTx(request) result.onSuccess { response -> val rawResponse = response.data?.rawResponse println("Chain: ${rawResponse?.chain}") println("Block: ${rawResponse?.block}") rawResponse?.validation?.let { validation -> println("Validation Status: ${validation.status}") println("Result Type: ${validation.resultType}") if (validation.resultType == "Malicious") { println("⚠️ Transaction flagged as MALICIOUS") } else { println("✅ Transaction appears safe") } } }.onFailure { error -> println("Blockaid Tron Scan failed: ${error.message}") } } catch (e: Exception) { println("Error scanning Tron transaction: ${e.message}") } } ``` The request object maps to the following fields: | Field | Type | Required | Description | | ---------------- | ------------------------------- | -------- | ---------------------------------------------------------- | | `chain` | `String` | Yes | Must be `tron:mainnet` | | `accountAddress` | `String?` | No | Sender's Tron address (Base58 format, starts with `T`) | | `data.from` | `String?` | No | Sender address (Base58 format) | | `data.to` | `String?` | No | Recipient address (Base58 format) | | `data.data` | `String?` | No | Encoded contract call data (hex) | | `data.value` | `String?` | No | Amount of TRX to transfer (hex) | | `metadata` | `BlockaidScanTronMetadata?` | No | Dapp or non-dapp context for the transaction | | `options` | `List?` | No | Analysis types to run: `SIMULATION`, `VALIDATION`, or both | *** ## Scanning Addresses Use `scanAddress` to analyze a single address for known security risks. This method can be used for both EVM and Solana addresses and detects malicious contracts, compromised wallets, sanctioned addresses, and other flagged entities. ### EVM Address Scan ```kotlin theme={null} lifecycleScope.launch { try { val request = BlockaidScanAddressRequest( address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC chain = "eip155:1" ) val result = portal.security.blockaid.scanAddress(request) result.onSuccess { response -> val rawResponse = response.data?.rawResponse println("Result Type: ${rawResponse?.resultType}") rawResponse?.features?.forEach { feature -> println("Feature: [${feature.type}] ${feature.featureId}: ${feature.description}") } }.onFailure { error -> println("EVM Address Scan failed: ${error.message}") } } catch (e: Exception) { println("Error scanning EVM address: ${e.message}") } } ``` ### Solana Address Scan ```kotlin theme={null} lifecycleScope.launch { try { val request = BlockaidScanAddressRequest( address = "So11111111111111111111111111111111111111112", // Wrapped SOL chain = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" ) val result = portal.security.blockaid.scanAddress(request) result.onSuccess { response -> val rawResponse = response.data?.rawResponse println("Result Type: ${rawResponse?.resultType}") rawResponse?.features?.forEach { feature -> println("Feature: [${feature.type}] ${feature.featureId}: ${feature.description}") } }.onFailure { error -> println("Solana Address Scan failed: ${error.message}") } } catch (e: Exception) { println("Error scanning Solana address: ${e.message}") } } ``` *** ## Scanning Tokens Use `scanTokens` to analyze multiple tokens in a single request for known security risks. This method detects scam tokens, honeypots, compromised contracts, and other malicious token behavior. ```kotlin theme={null} lifecycleScope.launch { try { val tokens = listOf( "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", // WETH "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" // USDC ) val request = BlockaidScanTokensRequest( chain = "eip155:1", tokens = tokens ) val result = portal.security.blockaid.scanTokens(request) result.onSuccess { response -> val resultsCount = response.data?.rawResponse?.results?.size ?: 0 println("Results count: $resultsCount") response.data?.rawResponse?.results?.forEach { (address, tokenResult) -> println("Token: $address") println(" Result Type: ${tokenResult.resultType}") tokenResult.maliciousScore?.let { println(" Malicious Score: $it") } tokenResult.metadata?.let { metadata -> println(" Name: ${metadata.name ?: "Unknown"}") println(" Symbol: ${metadata.symbol ?: "Unknown"}") } tokenResult.features?.forEach { feature -> println(" Feature: [${feature.type}] ${feature.featureId}: ${feature.description}") } if (tokenResult.resultType == "Malicious") { println(" ⚠️ Token is MALICIOUS") } } }.onFailure { error -> println("Blockaid Token Scan failed: ${error.message}") } } catch (e: Exception) { println("Error scanning tokens: ${e.message}") } } ``` *** ## Scanning URLs Use `scanURL` to detect phishing sites and malicious domains before users navigate to them. This method helps protect users from social engineering attacks and malicious off-chain activity. ```kotlin theme={null} lifecycleScope.launch { try { val request = BlockaidScanURLRequest( url = "https://app.uniswap.org" ) val result = portal.security.blockaid.scanURL(request) result.onSuccess { response -> val rawResponse = response.data?.rawResponse val status = rawResponse?.status ?: "Unknown" val isMalicious = rawResponse?.isMalicious ?: false println("URL: https://app.uniswap.org") println("Status: $status") println("Is Malicious: $isMalicious") if (status == "hit") { if (isMalicious) { println("⚠️ URL flagged as MALICIOUS") } else { println("✅ URL appears safe") } rawResponse?.maliciousScore?.let { println("Malicious Score: $it") } rawResponse?.isWeb3Site?.let { println("Is Web3 Site: $it") } rawResponse?.isReachable?.let { println("Is Reachable: $it") } } else if (status == "miss") { println("ℹ️ URL not in database (miss)") } }.onFailure { error -> println("Blockaid URL Scan failed: ${error.message}") } } catch (e: Exception) { println("Error scanning URL: ${e.message}") } } ``` *** ## Error Handling All Blockaid scan methods return a `Result` type that can be handled using Kotlin's `onSuccess` and `onFailure` callbacks. You should always handle both success and failure cases to ensure robust error handling. ```kotlin theme={null} lifecycleScope.launch { try { val result = portal.security.blockaid.scanEVMTx(request) result.onSuccess { response -> // Handle successful response val rawResponse = response.data?.rawResponse // Process the response... }.onFailure { error -> // Handle error println("Blockaid scan failed: ${error.message}") } } catch (e: Exception) { // Handle any unexpected exceptions println("Unexpected error: ${e.message}") } } ``` *** ## Next Steps * Learn about [signing transactions](./sign-a-transaction) * Explore [transaction evaluation](./evaluate-a-transaction) * Review [Portal API methods](./portal-api-methods) * Check out the [Blockaid Integration setup](../../../integrations/Security/blockaid) # Build a WebView Source: https://docs.portalhq.io/sdks/android/guide/build-a-webview This guide will walk you through how to build a WebView to interact with dApps using the Portal wallet. Once you have a Portal instance and you have generated a wallet, you can now create a WebView to interact with dApps using the Portal wallet. In the example below, we have an Activity that creates an instance of the `PortalWebView` Fragment and adds it to the view. ```kotlin theme={null} class PortalWebViewActivity() : AppCompatActivity() { private lateinit var portal: Portal private lateinit var webView: PortalWebView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_portal_webview) portal = (application as PortalApplication).getPortal() val url = "https://app.uniswap.org" webView = portal.createWebView(url) // This returns an instance of PortalWebView // Add the PortalWebView Fragment to the view val transaction = supportFragmentManager.beginTransaction() transaction.replace(R.id.webViewContainer, webView) transaction.commit() } fun goBack() { if (webView.canGoBack()) { webView.goBack() // Navigates the WebView to the previous page } } fun goForward() { if (webView.canGoForward()) { webView.goForward() // Navigates the WebView to the next page } } fun reload() { webView.reload() } } ``` And thats it! Remember that you will need a portal instance to start the web view and you will also need to initialize Portal with `autoApprove: true` if you want the web view to auto-approve transactions. ### Optional Parameters Following are the optional parameters that you can pass to customize the injected provider in the WebView further * **chainId:** Chain id you want to run the dapp on, by default it is `eip155:11155111` * **eip6963Icon:** A string representing the Base64-encoded icon for EIP-6963 compliance * **eip6963Name:** A string representing the name for EIP-6963 compliance. * **eip6963Rdns:** A reverse DNS string for identifying the application in EIP-6963-compliant contexts. * **eip6963Uuid:** A unique identifier string for EIP-6963 compliance. Next, let's explore how to use Portal's Swaps integration to perform swaps directly within your Android app. # Configure log level Source: https://docs.portalhq.io/sdks/android/guide/configure-log-level Control the verbosity of SDK log output at runtime using PortalLogLevel. By default, the Portal SDK emits no logs. You can enable logging at any verbosity level to help debug integration issues or monitor SDK behavior in development. ## Log levels The `PortalLogLevel` enum defines five levels. Each level includes all levels above it in severity. | Level | What is logged | | ---------------------- | --------------------------------------------------------------------------------------- | | `PortalLogLevel.NONE` | Nothing. This is the default. | | `PortalLogLevel.ERROR` | Failures only — failed transactions, network errors, binary crashes. | | `PortalLogLevel.WARN` | Unexpected but non-fatal conditions — deprecated usage, retries, slow responses. | | `PortalLogLevel.INFO` | Normal operational milestones — signing started, share generated, connection opened. | | `PortalLogLevel.DEBUG` | Everything, including internals — request/response payloads, timing, state transitions. | ## Set the log level Call `portal.setLogLevel()` after initializing your `Portal` instance. The change takes effect immediately across all SDK components. ```kotlin theme={null} import io.portalhq.android.Portal import io.portalhq.android.utils.PortalLogLevel class MainActivity : AppCompatActivity() { lateinit var portal: Portal override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) portal = Portal("YOUR_CLIENT_API_KEY") // Enable debug logging during development portal.setLogLevel(PortalLogLevel.DEBUG) } } ``` Set the log level before calling any other SDK methods to capture all output from the start. ## Recommended levels by environment * **Development**: `PortalLogLevel.DEBUG` — see all SDK activity while building your integration. * **QA / staging**: `PortalLogLevel.INFO` or `PortalLogLevel.WARN` — surface operational milestones and anomalies without noise. * **Production**: `PortalLogLevel.NONE` (default) — no logs emitted. Use `PortalLogLevel.ERROR` if you want to forward failures to a crash reporter. Do not use `PortalLogLevel.DEBUG` in production. Debug output includes request payloads and internal state that may contain sensitive data. ## Log output Logs are written to Android's standard logging system and appear in Logcat. You can filter them in Android Studio's Logcat window or via `adb logcat` by searching for the `PortalSDK` tag: ``` tag:PortalSDK ``` # Connect with WalletConnect Source: https://docs.portalhq.io/sdks/android/guide/connect-with-walletconnect Portal Connect allows you to connect your Portal MPC Wallet to any dApp that supports WalletConnect without extra dependency bloat. Portal Connect utilizes your existing `Portal` instance to connect your Portal MPC Wallet to dApps via WalletConnect. When connecting via Portal Connect, a few things happen in the background: 1. You provide a WalletConnect `uri` for Portal Connect to connect with 2. Portal Connect opens a WebSocket connection to the WalletConnect `uri` 3. All requests received over the WebSocket are routed to `portal.provider` to perform the request like normal This workflow allows for seamless integration between your Portal MPC Wallet and countless dApps that already include support for WalletConnect. ## Installing The `PortalConnect` class is included in the `portal-android` module, so no additional steps are required to start using `PortalConnect` in your app. ## Initializing In order to initialize `PortalConnect` within your app, you'll need to have a custom Application instance to ensure you can share the `Portal` instance between Activities (Alternatively if you are using Dagger2 then you can add it as Singleton to your ApplicationComponent. Idea is that the Portal object should have the same lifecycle as your application). Once this is done, you can initialize `Portal` like normal in your `MainActivity`, and initialize `PortalConnect` within a `PortalConnectActivity.` ### The custom Application class ```kotlin theme={null} package com.example.app import android.app.Application import io.portalhq.android.Portal class MyApplication: Application() { private lateinit var portal: Portal override fun onCreate() { super.onCreate(); } fun getPortal(): Portal? { return portal } fun setPortal(portal: Portal) { this.portal = portal } } ``` You'll also need to add the following property to the `application` node of your `AndroidManifest.xml`. This will register your application class as the class that is used when your app starts. ```xml theme={null} ``` ## Initializing `Portal` in your Activity In the Activity where you initialize `Portal`, you'll need to set the `Portal` instance on the Application. ```kotlin theme={null} portal = Portal( "YOUR_CLIENT_API_KEY", YOUR_GDRIVE_INSTANCE, chainId, YOUR_KEYCHAIN_INSTANCE, mapOf(5 to "https://eth-$chain.g.alchemy.com/v2/$alchemyApiKey"), autoApprove = true, ) (application as MyApplication).setPortal(portal) ``` To initialize Portal Connect, call `portal.createPortalConnectInstance()`. ```kotlin theme={null} package com.example.app import androidx.appcompat.app.AppCompatActivity import io.portalhq.android.Portal import io.portalhq.android.connect.PortalConnect class PortalConnectActivity() : AppCompatActivity() { private lateinit var portal: Portal private lateinit var connect: PortalConnect override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Define portal portal = (application as MyApplication).getPortal()!! // Create PortalConnect connect = portal.createPortalConnectInstance() } } ``` ## Connecting via WalletConnect To connect to a WalletConnect URI, using the mechanism that makes the most sense for your app, capture the WalletConnect URI. The most common practice is to use a QR Code scanner for this. Once you've captured the URI, you can call the `connect(uri)` function on your `PortalConnect` instance. This will initialize the WebSocket session with the WalletConnect relay and begin passing messages to your Portal Provider using your Portal MPC Wallet. ```kotlin theme={null} connectButton.setOnClickListener { val uri = wcUriTextInput.text.toString() connect.connect(uri) } ``` Listen for the `connect` and `disconnect` events to get confirmation that you have successfully connected or if the user disconnects. ```kotlin theme={null} connect.on(ConnectEvents.Connect.value) { data: Any -> } ``` ```kotlin theme={null} connect.on(ConnectEvents.Disconnect.value) { data: Any -> } ``` Once this is done, you've got a connection to WalletConnect. All messages sent by the relay will now be processed by the Portal Provider. Wallet Connect's [Auth API ](https://docs.walletconnect.com/web3wallet/wallet-usage#auth-requests)is not currently supported. We have it on our roadmap. Please reach out if this is an urgent feature request. ## Handling Session Requests `Session Requests` represent the initial connection request from the dApp to create a new session. These are triggered when Portal responds to the dApp after the `connect(uri)` function is called. These events will be triggered with a `SessionProposal` object. These objects can be used to display information about the dApp the user is connecting to and the specific permissions being requested by the dApp. ### Accepting the dApps proposal We have a helper method `addChainsToProposal` . This can be used to add all the chains in your gateway config to the proposal object. ### Binding to Session Requests In order to bind to Session Requests, add an event handler to your `PortalConnect` instance. ```kotlin theme={null} connect.on("portal_dappSessionRequested") { data: Any -> // ... your business logic if (approved) { var connectData = data as SessionMessageData var newProposal = connect.addChainsToProposal(connectData) connect.emit(ConnectEvents.DappSessionApproved.value, newProposal) } else { connect.emit('portal_dappSessionRejected', data) } } ``` ### Retrieve Session Request Portal stores session requests for up to 24 hours. This feature can be useful if PortalConnect loses connection with the dApp being used and you want to respond to previous session requests. To retrieve a previous session request, use the `emitGetSessionRequest` method. You will need to provide both the `requestId` and the `topic`. ```swift theme={null} connect.emitGetSessionRequest( "requestId", "topic" ) ``` **Important Notes:** * You can only respond to a session request **once**. * Ensure you have stored the correct **`requestId`** and **`topic`** values to successfully retrieve the session request. This functionality helps maintain continuity in dApp interactions by allowing you to handle any outstanding session requests even after a connection disruption. ## Handle Signing Each portal connect instance gets created with its own instance of the Portal Provider. This allows users to connect with different chains to different dApps. #### Listen for signing events Handle signing approval. This is only required if `autoApprove` is turned off. ```kotlin theme={null} connect.on(ConnectEvents.PortalSigningRequested.value) { } ``` Listen for confirmation of signing requests ```kotlin theme={null} connect.on(ConnectEvents.PortalSignatureReceived.value) { } ``` ## Handle Warnings and Errors Set up a listener for `portal_connectError` in order to handle specific errors and warnings from Portal Connect. Check out our Portal Connect Error Codes [here](../../../resources/error-codes). ## Switching Chains In order to switch the active chain for a portal connect instance use the `updateChainId` method. You must include that `chainId` in the gateway config with a gateway url on initialization of your Portal Object, otherwise, you can not switch chains. ```kotlin theme={null} connect.updateChainId(137) // switches the active chain to polygon mainnet ``` ## The final PortalConnectActivity class Once you've gone through that process, you should have a file that looks something like this. ```kotlin theme={null} package com.example.app import android.widget.Button import androidx.appcompat.app.AppCompatActivity import io.portalhq.android.Portal import io.portalhq.android.connect.PortalConnect class PortalConnectActivity() : AppCompatActivity() { private lateinit var portal: Portal private lateinit var connect: PortalConnect // UI Elements private lateinit var connectButton: Button private lateinit var wcUriTextInput: EditText override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Define portal portal = (application as MyApplication).getPortal()!! // Create PortalConnect connect = PortalConnect(portal) // Bind to Dapp Session Requests connect.on("portal_dappSessionRequested") { data: Any -> // ... your business logic if (approved) { connect.emit("portal_dappSessionApproved", data) } else { connect.emit("portal_dappSessionRejected", data) } } // Bind to text change events wcUriTextInput.addTextChangedListener(object: TextWatcher { override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { connectButton.isEnabled = (s?.length ?: 0) > 0 && s?.startsWith("wc:") ?: false } }) // Bind to connect action connectButton.setOnClickListener { val uri = wcUriTextInput.text.toString() connect.connect(uri) } } } ``` # Create a wallet Source: https://docs.portalhq.io/sdks/android/guide/create-a-wallet This guide will walk you through how to create a wallet using the Portal Android SDK. In the MPC **wallet generation** process, two sets of key shares are generated. The **signing key shares** are used for signing transactions, and the **backup key shares** are used for recovery if the device storing a signing key shares is lost. The `generate` command initiates the MPC process to create a set of shares. The users share and the wallet address is automatically stored in the user's secure phone storage. [Once you have a Portal instance](./create-a-wallet), you can now generate a wallet. In the example below, we create a `Generate Wallet` button: ```kotlin theme={null} // Imports... class MainActivity : AppCompatActivity() { lateinit var portal: Portal lateinit var generateButton: Button override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) generateButton = findViewById(R.id.generateButton) generateButton.setOnClickListener { handleGenerate() } } private fun handleGenerate() { lifecycleScope.launch { portal.createWallet { status -> // Do something with the status, such as update a progress bar // or log the progress Log.d( "[PortalEx]", "Generate status: ${status.status} is done: ${status.done}", ) }.onSuccess { walletAddresses -> Log.d( "[PortalEx]", "✅ Generated address successfully! $walletAddresses", ) }.onFailure { e -> Log.d("[PortalEx]", "Error generating share: ${e.stackTraceToString()}") } } } } ``` Want faster wallet creation? Enable the [`usePreGeneratedWallet`](./feature-flags#usepregeneratedwallet) feature flag—no changes to the code above are needed. Be sure to use a **Production** API key when creating clients for production. Read more details about going to production [here](../../../resources/going-to-production). **WARNING**: To create a wallet with the Portal SDK, your device must be configured to use passcode authentication. If you change your passcode, your Portal wallet will continue to operate as expected. However, if you disable passcode authentication after running the `generate` function, you will be required to execute the `recover` function before you can continue using your Portal wallet. ### Status Flow ```kotlin theme={null} 1. GeneratingShare("Generating share") 2. ParsingShare("Parsing share") 3. StoringShare("Storing share") 4. Done("Done") ``` Now that you've generated a wallet, let's back it up in the next section. # Cross-device sessions Source: https://docs.portalhq.io/sdks/android/guide/cross-device-sessions This guide will walk you through how to use wallet provisioning to enable your users to create multiple signing shares across their devices. Once users successfully back up their wallets, they can generate a new set of signing shares irrespective of the SDK in use. Furthermore, both the new and old sets of signing shares can be used simultaneously, thanks to **`portal.provisionWallet`**. This provisioning relies on the user already having a successful set of backup shares. While provisioning, the wallet fetches the encryption key from the user's cloud storage provider to decrypt the encrypted client backup share. Note that our SDK handles the decryption; you only need to supply the encrypted client backup share. For wallet provisioning support, consult [the recovery documentation](./recover-a-wallet) to establish the necessary **`/backup/fetch`** webhook. Also, ensure the user has successfully completed the backup process. Here's how to implement **`portal.provisionWallet`**: ```kotlin theme={null} // Imports... import kotlinx.coroutines.* class MainActivity : AppCompatActivity() { lateinit var portal: Portal lateinit var provisionButton: Button // Your API instance. private val exchangeApi: Api = Api() // The user from your API instance. lateinit var user: User override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) provisionButton = findViewById(R.id.provisionButton) provisionButton.setOnClickListener { handleProvision() } } private fun handleProvision() { lifecycleScope.launch { try { // Retrieve the existing encrypted client backup share from your API. val backupShare = exchangeApi.getCipherText(user.id) // Set a new signing share by running provisionWallet. portal.provisionWallet(backupShare) { status -> // Ensure UI interactions are done on the main thread // Do something with the status, such as update a progress bar // or log the progress Log.i("[PORTAL]", "Provision wallet status: ${status.status}, is done: ${status.done}") } } catch (e: Exception) { // ❌ Handle any errors provisioning the wallet. Log.e("[PORTAL]", "Error provisioning wallet: ${e.message}") } } } } ``` And that's it! You're now equipped to support multiple sessions across different devices for your users. # Manage Token Delegations Source: https://docs.portalhq.io/sdks/android/guide/delegations Learn how to approve, revoke, and manage token delegations using Portal's Android SDK. Portal's Android SDK provides token delegation capabilities through the `portal.delegations` API. This enables approving token spending, revoking approvals, checking delegation status, and transferring tokens as a delegate on both EVM and Solana chains. ## Overview The delegations functionality allows you to: * **Approve** other addresses to spend tokens on behalf of your wallet * **Revoke** existing delegations to remove spending permissions * **Check status** of active delegations and balances * **Transfer tokens** as a delegate from another address ## Prerequisites Before using delegation operations, ensure you have: * A properly initialized Portal client * An active wallet with tokens on the target network (see [Create a wallet](./create-a-wallet)) * Understanding of [token delegations concepts](/resources/delegations) Delegations apply to ERC-20 tokens (EVM) and SPL Tokens (Solana) only. Native assets like ETH, MON, and SOL cannot be delegated — they have no on-chain `approve` / `transferFrom` (or SPL delegate) semantics. Calls using a native asset identifier will be rejected. See [Delegations](/resources/delegations#what-are-token-delegations) for the protocol-level reason and workarounds. ## High-Level Methods Use `approveAndSubmit`, `revokeAndSubmit`, and `transferAndSubmit` when you want one call for the whole flow: build the delegation transaction(s), then sign and broadcast each one in order and collect the resulting hashes. `Portal` installs a working signer on `portal.delegations` for you, so the common case needs no configuration at all. `approveAndSubmit`, `revokeAndSubmit`, and `transferAndSubmit` broadcast each transaction and return as soon as it is accepted by the network. They **do not wait for on-chain confirmation**. A hash in `DelegationSubmitResult.hashes` means the transaction was submitted, not that it succeeded — an approval can still revert. If your flow depends on the delegation being active, wait for the receipt yourself, or poll `getStatus` before proceeding. This is different from [Yield.xyz](./yield-xyz), where you wait for each transaction to confirm before moving on to the next step. Do not carry that assumption over to delegations. ### Signatures ```kotlin theme={null} fun setSignAndSendTransaction(fn: DelegationSignAndSend) suspend fun approveAndSubmit( request: ApproveDelegationRequest, options: DelegationSubmitOptions = DelegationSubmitOptions() ): Result suspend fun revokeAndSubmit( request: RevokeDelegationRequest, options: DelegationSubmitOptions = DelegationSubmitOptions() ): Result suspend fun transferAndSubmit( request: TransferFromRequest, options: DelegationSubmitOptions = DelegationSubmitOptions() ): Result ``` `options` is defaulted, so `portal.delegations.approveAndSubmit(request)` is a complete call. All three return `Result` — handle failures via `onSuccess` / `onFailure` (or `fold`), since errors are returned in the `Result` rather than thrown. The request types are the same ones the low-level methods take — see [EVM Approval](#evm-approval), [EVM Revoke](#evm-revoke), and [EVM Transfer From](#evm-transfer-from) below for their fields. ### Configuring the signer A signer signs and broadcasts one transaction and returns its hash: ```kotlin theme={null} typealias DelegationSignAndSend = suspend (transaction: DelegationTransaction, chainId: String) -> String ``` `DelegationTransaction` is a sealed class covering both ecosystems: ```kotlin theme={null} sealed class DelegationTransaction { data class Evm(val transaction: ConstructedEipTransaction) : DelegationTransaction() data class Solana(val encodedTransaction: String) : DelegationTransaction() } ``` `Solana.encodedTransaction` is base64-encoded. Because it is a sealed class, a `when` over it is exhaustive and needs no `else`. `Portal` installs a default signer that routes `Evm` transactions to `eth_sendTransaction` and `Solana` transactions to `sol_signAndSendTransaction`: ```kotlin theme={null} val delegations: Delegations by lazy { Delegations(this.api.delegations).also { d -> d.setSignAndSendTransaction(::signDelegationTransaction) } } ``` So this works with zero setup: ```kotlin theme={null} portal.delegations.approveAndSubmit(request) ``` Use `setSignAndSendTransaction(fn)` to replace the signer for the instance, or `DelegationSubmitOptions.signAndSendTransaction` to override it for a single call. The precedence is **per-call option → instance signer → `Portal` default**. ### Options and progress `DelegationSubmitOptions` is the second argument to each submit method: | Parameter | Type | Required | Description | | ------------------------ | --------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------- | | `signAndSendTransaction` | `DelegationSignAndSend?` | No | Per-call signer override. Takes priority over the instance signer set with `setSignAndSendTransaction(fn)`. Defaults to `null`. | | `onProgress` | `((DelegationSubmitProgress) -> Unit)?` | No | Called as each transaction is signed and submitted. Defaults to `null`. | `DelegationSubmitProgress` carries: | Field | Type | Description | | ------- | ---------------------- | ----------------------------------------------------------------------- | | `step` | `DelegationSubmitStep` | `SIGNING` or `SUBMITTED`. | | `index` | `Int` | The 0-based index of this transaction in the sequence. | | `total` | `Int` | The total number of transactions in the sequence. | | `hash` | `String?` | `null` on `SIGNING`, and the broadcast transaction hash on `SUBMITTED`. | `DelegationSubmitStep` has exactly two members — `SIGNING` and `SUBMITTED`. There is no confirming or confirmed step, because nothing is awaited on-chain. `DelegationSubmitStep` members are uppercase (`DelegationSubmitStep.SIGNING`), while `YieldSubmitStep` members are lowercase (`YieldSubmitStep.signing`). Both shipped in 9.1.0. Write each one the way its own type declares it. ### Return value `DelegationSubmitResult` carries only the hashes: | Field | Type | Description | | -------- | -------------- | -------------------------------------------------------- | | `hashes` | `List` | One hash per broadcast transaction, in submission order. | There is no status field, and no partial-success concept: a hash is present because the network accepted the transaction, and nothing beyond that has been checked. ### Example (approve and submit) This builds the same approval as [EVM Approval](#evm-approval) below, but signs and broadcasts it for you. ```kotlin theme={null} import io.portalhq.android.api.data.delegations.ApproveDelegationRequest import io.portalhq.android.delegations.DelegationSubmitOptions import io.portalhq.android.delegations.DelegationSubmitStep lifecycleScope.launch { val request = ApproveDelegationRequest( chain = "eip155:11155111", // Sepolia testnet token = "USDC", delegateAddress = "0x1a3eda7eb7d13e60e638711c580490c19e164fee", amount = "0.01" ) portal.delegations.approveAndSubmit( request = request, options = DelegationSubmitOptions( onProgress = { progress -> when (progress.step) { DelegationSubmitStep.SIGNING -> println("Signing ${progress.index + 1}/${progress.total}") DelegationSubmitStep.SUBMITTED -> println("Submitted: ${progress.hash}") } } ) ).onSuccess { result -> // These are broadcast, not confirmed. println("Submitted hashes: ${result.hashes}") }.onFailure { error -> println("approveAndSubmit failed: ${error.message}") } } ``` The `when` over `progress.step` is exhaustive with no `else` — there really are only two steps. ### Example (revoke and submit) Same `RevokeDelegationRequest` as [EVM Revoke](#evm-revoke) below, submitted end to end. ```kotlin theme={null} import io.portalhq.android.api.data.delegations.RevokeDelegationRequest lifecycleScope.launch { val request = RevokeDelegationRequest( chain = "eip155:11155111", token = "USDC", delegateAddress = "0x1a3eda7eb7d13e60e638711c580490c19e164fee" ) portal.delegations.revokeAndSubmit(request).onSuccess { result -> println("Revoke submitted: ${result.hashes}") }.onFailure { error -> println("revokeAndSubmit failed: ${error.message}") } } ``` Because the revoke is not confirmed when this call returns, the delegation may still be active for a short time afterwards. Poll `getStatus` if you need to show the user that it is gone. ### Example (transfer as a delegate) Same `TransferFromRequest` as [EVM Transfer From](#evm-transfer-from) below. Your wallet must already be an approved delegate for `fromAddress`. ```kotlin theme={null} import io.portalhq.android.api.data.delegations.TransferFromRequest lifecycleScope.launch { val request = TransferFromRequest( chain = "eip155:11155111", token = "USDC", fromAddress = "0x06ccd61bc37775140b0b039b392aa823c7cbeedd", // Token owner toAddress = "0x5bd098a9368d142126e8b53a058b5c563714bc76", // Recipient amount = "0.01" ) portal.delegations.transferAndSubmit(request).onSuccess { result -> println("Transfer submitted: ${result.hashes}") }.onFailure { error -> println("transferAndSubmit failed: ${error.message}") } } ``` ### Example (custom signer) Replace the default signer when you need to do something it does not, such as attaching a `signatureApprovalMemo` to every delegation transaction. A `when` over the sealed class is exhaustive and returns the hash directly: ```kotlin theme={null} import io.portalhq.android.delegations.DelegationTransaction import io.portalhq.android.provider.data.PortalRequestMethod import io.portalhq.android.provider.data.RequestOptions portal.delegations.setSignAndSendTransaction { transaction, chainId -> val (method, param) = when (transaction) { is DelegationTransaction.Evm -> PortalRequestMethod.eth_sendTransaction to transaction.transaction is DelegationTransaction.Solana -> PortalRequestMethod.sol_signAndSendTransaction to transaction.encodedTransaction } val response = portal.request( chainId = chainId, method = method, params = listOf(param), options = RequestOptions(signatureApprovalMemo = "Delegation transaction") ) // Returning a blank hash makes the submit method fail with // DelegationsError.InvalidTransactionHash, tagged with the right index. response.result as? String ?: "" } ``` To use a different signer for a single call, pass it in the options instead — it takes priority over the instance signer: ```kotlin theme={null} import io.portalhq.android.api.data.delegations.ApproveDelegationRequest import io.portalhq.android.delegations.DelegationSubmitOptions import io.portalhq.android.delegations.DelegationTransaction import io.portalhq.android.provider.data.PortalRequestMethod import io.portalhq.android.provider.data.RequestOptions lifecycleScope.launch { val request = ApproveDelegationRequest( chain = "eip155:11155111", token = "USDC", delegateAddress = "0x1a3eda7eb7d13e60e638711c580490c19e164fee", amount = "0.01" ) portal.delegations.approveAndSubmit( request = request, options = DelegationSubmitOptions( signAndSendTransaction = { transaction, chainId -> val (method, param) = when (transaction) { is DelegationTransaction.Evm -> PortalRequestMethod.eth_sendTransaction to transaction.transaction is DelegationTransaction.Solana -> PortalRequestMethod.sol_signAndSendTransaction to transaction.encodedTransaction } portal.request( chainId = chainId, method = method, params = listOf(param), options = RequestOptions(signatureApprovalMemo = "One-off delegation approval") ).result as? String ?: "" } ) ).onSuccess { result -> println("Submitted hashes: ${result.hashes}") }.onFailure { error -> println("approveAndSubmit failed: ${error.message}") } } ``` ### Errors `DelegationsError` is a sealed class of `Exception` subclasses. They arrive inside a failed `Result`, so match them in `onFailure`: | Case | When | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `NoSignerConfigured` | No signer was available. This cannot happen on `portal.delegations`, which `Portal` wires for you — only when you construct `Delegations` yourself. | | `NoTransactions` | The delegation response contained no transactions to submit. | | `InvalidTransactionHash` | The signer returned a value that is not a usable hash for that chain. The message names which transaction in the sequence it was. | ```kotlin theme={null} import io.portalhq.android.api.data.delegations.ApproveDelegationRequest import io.portalhq.android.delegations.DelegationsError lifecycleScope.launch { val request = ApproveDelegationRequest( chain = "eip155:11155111", token = "USDC", delegateAddress = "0x1a3eda7eb7d13e60e638711c580490c19e164fee", amount = "0.01" ) portal.delegations.approveAndSubmit(request).onSuccess { result -> println("Submitted hashes: ${result.hashes}") }.onFailure { error -> when (error) { is DelegationsError.NoTransactions -> println("The approval response contained no transactions to submit.") is DelegationsError.InvalidTransactionHash -> println("The signer returned an unusable hash: ${error.message}") is DelegationsError.NoSignerConfigured -> println("No signer configured on this Delegations instance.") else -> println("approveAndSubmit failed: ${error.message}") } } } ``` The submit methods can also fail with the network and decoding errors the low-level methods return. Coroutine cancellation is never captured in the `Result` — it is rethrown, so structured concurrency still works when the calling scope is torn down. *** ## Low-Level Methods The sections below are the manual path: `approve`, `revoke`, `transferFrom`, and `getStatus` return unsigned transactions and leave signing and broadcasting to you. Use them when you need to inspect, modify, batch, or route the transactions yourself. Otherwise prefer the high-level methods above. ## Approving Delegations Use `approve` to grant another address permission to spend tokens on your behalf. This method works for both EVM and Solana chains. ### EVM Approval ```kotlin theme={null} lifecycleScope.launch { try { val request = ApproveDelegationRequest( chain = "eip155:11155111", // Sepolia testnet token = "USDC", delegateAddress = "0x1a3eda7eb7d13e60e638711c580490c19e164fee", amount = "0.01" ) val result = portal.delegations.approve(request) result.onSuccess { response -> // Sign and send EVM transactions sequentially response.transactions?.let { transactions -> for ((index, tx) in transactions.withIndex()) { val txDict = mutableMapOf( "from" to tx.from, "to" to tx.to ) tx.data?.let { txDict["data"] = it } tx.value?.let { txDict["value"] = it } val txResponse = portal.request( chainId = "eip155:11155111", method = PortalRequestMethod.eth_sendTransaction, params = listOf(txDict), options = RequestOptions(signatureApprovalMemo = "Approve delegation") ) println("Tx ${index + 1} hash: ${txResponse.result as? String}") } } }.onFailure { error -> println("Approve (EVM) failed: ${error.message}") } } catch (e: Exception) { println("Error approving EVM delegation: ${e.message}") } } ``` ### Solana Approval ```kotlin theme={null} lifecycleScope.launch { try { val request = ApproveDelegationRequest( chain = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", // Solana Devnet token = "USDC", delegateAddress = "8uzXpjP9zXRHqo6KGZaE6XrxnjarBsKTufVya7jHtyt5", amount = "0.01" ) val result = portal.delegations.approve(request) result.onSuccess { response -> // Sign and send Solana transactions sequentially response.encodedTransactions?.let { encodedTxs -> for ((index, encodedTx) in encodedTxs.withIndex()) { val txResponse = portal.request( chainId = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", method = PortalRequestMethod.sol_signAndSendTransaction, params = listOf(encodedTx), options = RequestOptions(signatureApprovalMemo = "Approve delegation") ) println("Tx ${index + 1} hash: ${txResponse.result as? String}") } } }.onFailure { error -> println("Approve (SOL) failed: ${error.message}") } } catch (e: Exception) { println("Error approving Solana delegation: ${e.message}") } } ``` *** ## Checking Delegation Status Use `getStatus` to check current delegations and token balances for a specific delegate address. ### EVM Status Check ```kotlin theme={null} lifecycleScope.launch { try { val request = GetDelegationStatusRequest( chain = "eip155:11155111", token = "USDC", delegateAddress = "0x1a3eda7eb7d13e60e638711c580490c19e164fee" ) val result = portal.delegations.getStatus(request) result.onSuccess { response -> println("Chain ID: ${response.chainId}") println("Token: ${response.token}") println("Token Address: ${response.tokenAddress}") response.balance?.let { println("Balance: $it") } println("Delegations: ${response.delegations.size}") response.delegations.forEach { delegation -> println(" - Address: ${delegation.address}, Amount: ${delegation.delegateAmount}") } }.onFailure { error -> println("Get Status (EVM) failed: ${error.message}") } } catch (e: Exception) { println("Error getting EVM delegation status: ${e.message}") } } ``` ### Solana Status Check ```kotlin theme={null} lifecycleScope.launch { try { val request = GetDelegationStatusRequest( chain = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", token = "USDC", delegateAddress = "8uzXpjP9zXRHqo6KGZaE6XrxnjarBsKTufVya7jHtyt5" ) val result = portal.delegations.getStatus(request) result.onSuccess { response -> println("Chain ID: ${response.chainId}") println("Token: ${response.token}") println("Delegations: ${response.delegations.size}") response.delegations.forEach { delegation -> println(" - Address: ${delegation.address}, Amount: ${delegation.delegateAmount}") } }.onFailure { error -> println("Get Status (SOL) failed: ${error.message}") } } catch (e: Exception) { println("Error getting Solana delegation status: ${e.message}") } } ``` *** ## Revoking Delegations Use `revoke` to remove spending permissions from a delegate address. ### EVM Revoke ```kotlin theme={null} lifecycleScope.launch { try { val request = RevokeDelegationRequest( chain = "eip155:11155111", token = "USDC", delegateAddress = "0x1a3eda7eb7d13e60e638711c580490c19e164fee" ) val result = portal.delegations.revoke(request) result.onSuccess { response -> // Sign and send EVM transactions sequentially response.transactions?.let { transactions -> for ((index, tx) in transactions.withIndex()) { val txDict = mutableMapOf( "from" to tx.from, "to" to tx.to ) tx.data?.let { txDict["data"] = it } tx.value?.let { txDict["value"] = it } val txResponse = portal.request( chainId = "eip155:11155111", method = PortalRequestMethod.eth_sendTransaction, params = listOf(txDict), options = RequestOptions(signatureApprovalMemo = "Revoke delegation") ) println("Tx ${index + 1} hash: ${txResponse.result as? String}") } } }.onFailure { error -> println("Revoke (EVM) failed: ${error.message}") } } catch (e: Exception) { println("Error revoking EVM delegation: ${e.message}") } } ``` ### Solana Revoke ```kotlin theme={null} lifecycleScope.launch { try { val request = RevokeDelegationRequest( chain = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", token = "USDC", delegateAddress = "8uzXpjP9zXRHqo6KGZaE6XrxnjarBsKTufVya7jHtyt5" ) val result = portal.delegations.revoke(request) result.onSuccess { response -> // Sign and send Solana transactions sequentially response.encodedTransactions?.let { encodedTxs -> for ((index, encodedTx) in encodedTxs.withIndex()) { val txResponse = portal.request( chainId = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", method = PortalRequestMethod.sol_signAndSendTransaction, params = listOf(encodedTx), options = RequestOptions(signatureApprovalMemo = "Revoke delegation") ) println("Tx ${index + 1} hash: ${txResponse.result as? String}") } } }.onFailure { error -> println("Revoke (SOL) failed: ${error.message}") } } catch (e: Exception) { println("Error revoking Solana delegation: ${e.message}") } } ``` Always revoke unused delegations after completing operations to minimize security risks. *** ## Transferring as a Delegate Use `transferFrom` to transfer tokens from another address that has delegated spending permission to you. ### EVM Transfer From ```kotlin theme={null} lifecycleScope.launch { try { val request = TransferFromRequest( chain = "eip155:11155111", token = "USDC", fromAddress = "0x06ccd61bc37775140b0b039b392aa823c7cbeedd", // Token owner toAddress = "0x5bd098a9368d142126e8b53a058b5c563714bc76", // Recipient amount = "0.01" ) val result = portal.delegations.transferFrom(request) result.onSuccess { response -> // Sign and send EVM transactions sequentially response.transactions?.let { transactions -> for ((index, tx) in transactions.withIndex()) { val txDict = mutableMapOf( "from" to tx.from, "to" to tx.to ) tx.data?.let { txDict["data"] = it } tx.value?.let { txDict["value"] = it } val txResponse = portal.request( chainId = "eip155:11155111", method = PortalRequestMethod.eth_sendTransaction, params = listOf(txDict), options = RequestOptions(signatureApprovalMemo = "Transfer delegated tokens") ) println("Tx ${index + 1} hash: ${txResponse.result as? String}") } } }.onFailure { error -> println("TransferFrom (EVM) failed: ${error.message}") } } catch (e: Exception) { println("Error transferring EVM delegation: ${e.message}") } } ``` ### Solana Transfer From ```kotlin theme={null} lifecycleScope.launch { try { val request = TransferFromRequest( chain = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", token = "USDC", fromAddress = "5qf7h6aJ47nfYkmtDW52LtCgBKEppf8CU1CmNXUJvPDD", // Token owner toAddress = "CaeuusKjRDw2NctShW2gMhEcMxFYfHLUUGHh3Hui3Mae", // Recipient amount = "0.01" ) val result = portal.delegations.transferFrom(request) result.onSuccess { response -> // Sign and send Solana transactions sequentially response.encodedTransactions?.let { encodedTxs -> for ((index, encodedTx) in encodedTxs.withIndex()) { val txResponse = portal.request( chainId = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", method = PortalRequestMethod.sol_signAndSendTransaction, params = listOf(encodedTx), options = RequestOptions(signatureApprovalMemo = "Transfer delegated tokens") ) println("Tx ${index + 1} hash: ${txResponse.result as? String}") } } }.onFailure { error -> println("TransferFrom (SOL) failed: ${error.message}") } } catch (e: Exception) { println("Error transferring Solana delegation: ${e.message}") } } ``` **Delegation Roles**: `fromAddress` is the token owner who approved the delegation. Your wallet (the delegate) signs the transaction to transfer tokens from the owner to the `toAddress` recipient. *** ## Supported Networks Delegations work on all Portal-supported EVM and Solana chains: * **EVM**: Ethereum, Polygon, Base, Arbitrum, Optimism, Monad, and all other EVM-compatible chains * **Solana**: Solana Mainnet and Devnet For a complete list, see [Blockchain Support](/resources/blockchain-support). *** ## Next Steps * Submit delegations in one call with the [high-level methods](#high-level-methods) * Learn about [signing transactions](./sign-a-transaction) * Explore [Portal API methods](./portal-api-methods) * Review [delegation concepts](/resources/delegations) * Check out [wallet lifecycle management](./manage-wallet-lifecycle-states) # Eject a wallet Source: https://docs.portalhq.io/sdks/android/guide/eject-a-wallet This guide will walk you through how to eject a wallet using the Portal Android SDK. **Warning:** Providing the custodian backup share to the client device puts both MPC shares on a single device, removing the multi-party security benefits of MPC. This operation should only be done for users who want to move off of MPC and into a single private key. **Use `portal.eject()` at your own risk!** ## Ejecting Your User's Wallets ### With Portal-managed backups To eject the private keys for your users' wallets, their two matching backup shares need to be combined. Since both of these shares are stored on the Portal backend encrypted at rest, we first need to verify that the upcoming request to eject the wallet is not an attack. In order to do this, we make this a two-step process: 1. Make a Custodian-authed `POST` request using your Custodian API Key (the same API key you use to create new users) to the `/api/v3/custodians/me/clients/${clientId}/prepare-eject` endpoint to allow for this client to perform an `eject` operation 2. Call the `portal.ejectPrivateKey()` function from your application #### Making the \`prepare-eject\` request This endpoint requires a `walletId` in the `POST` body. This can be found in the `wallets` property of the `/api/custodians/me/clients/${clientId}` `GET` request. If you are planning on ejecting multiple wallets for a user, you'll need to make multiple separate requests to "unlock" all requires wallets. ```typescript theme={null} const response = await axios.post( `https://api.portalhq.io/api/v3/custodians/me/clients/${clientId}/prepare-eject`, { walletId: 'YOUR_CLIENTS_WALLET_ID' }, { headers: { 'Authorization': `Bearer ${YOUR_CUSTODIAN_API_KEY}` } }, ) ``` #### Ejecting the wallets ```kotlin theme={null} val privateKey = portal.ejectPrivateKey( null, // No input backup shares are required null, // No input backup shares are required // Provide the appropriate backup method for your client's wallet backupMethod = BackupMethods.Password, backupConfigs = backupConfigs, ) ``` This function will perform the combining of shares from our server and the cryptographic operation required to build a private key for the wallet. The return value is the string literal of the private key. ### With Backups from your server To eject the private keys for your users' wallets, their two matching backup shares need to be combined. This requires providing the following two shares to `portal.eject()`: * **User Backup Share** - Encrypted backup share received from `portal.backup()`. * **Custodian Backup Share** - Raw backup share received from the `POST /backup` webhook. * **Custodian Backup Solana Share** - Raw backup share received from the `POST /backup` webhook. Here's an example implementation: ```kotlin theme={null} // Create a backup config for portal.eject(). val backupConfig = BackupConfigs(PasswordStorageConfig(password = "USERS_PASSWORD")) // Fetch the user's backup share from your API. val userBackupShare = yourAPI.fetchUserBackupShare(userId) // Fetch the custodian backup share for the user from your API. val custodianBackupShare = yourAPI.fetchCustodianBackupShare(userId) // Fetch the custodian backup share for the user from your API. val custodianBackupSolanaShare = yourAPI.fetchCustodianBackupSolanaShare(userId) // Obtain the private key for the user's SECP256K1 and ED25519 wallet. val privateKeys = portal.ejectPrivateKeys( userBackupShare, custodianBackupShare, custodianBackupSolanaShare, BackupMethods.Password, backupConfig, ) ``` **`SECP256K1`** is the curve used by Ethereum and **ED25519** is the curve used by solana. You can learn more about signing algorithms and curves [here](http://ethanfast.com/top-crypto.html). # Error handling Source: https://docs.portalhq.io/sdks/android/guide/error-handling Handle structured exceptions thrown by the Portal Android SDK using the PortalException hierarchy. The Portal Android SDK uses a structured `PortalException` hierarchy so you can catch errors at the granularity you need — from individual error cases up to broad categories. Every exception thrown by the SDK is a subclass of `PortalException`, which itself extends `Exception`. ## Exception hierarchy `PortalException` is a sealed class with eight category-level sealed subclasses: | Category | Description | | ----------------------------- | ------------------------------------------------------------ | | `PortalException.Mpc` | MPC key generation, backup, recovery, and signing operations | | `PortalException.Storage` | Google Drive, Passkey, and Keychain storage operations | | `PortalException.Wallet` | Wallet state and availability | | `PortalException.Transaction` | Transaction building and asset transfers | | `PortalException.Api` | HTTP requests, RPC calls, and API errors | | `PortalException.Connect` | PortalConnect (WalletConnect) operations | | `PortalException.Provider` | Provider request routing and validation | | `PortalException.Blockchain` | Chain ID parsing and namespace resolution | ## Catching exceptions Because the hierarchy is sealed, you can catch at any level of specificity. ### Catch a specific exception ```kotlin theme={null} try { portal.backupWallet(backupMethod = BackupMethods.Passkey) } catch (e: PortalException.Mpc.BackupMethodNotConfigured) { println("Passkey backup is not configured: ${e.message}") } catch (e: PortalException.Storage.PasskeyNotSupported) { println("This device does not support passkeys: ${e.message}") } ``` ### Catch an entire category ```kotlin theme={null} try { portal.backupWallet() } catch (e: PortalException.Mpc) { println("MPC operation failed: ${e.message}") } catch (e: PortalException.Storage) { println("Storage operation failed: ${e.message}") } ``` ### Catch any Portal exception ```kotlin theme={null} try { portal.createWallet().getOrThrow() } catch (e: PortalException) { println("Portal error: ${e.message}") } ``` ### Handle errors from `Result`-returning methods Some methods like `sendAsset` return a `Result` instead of throwing. Use `onFailure` or `exceptionOrNull()` to inspect the error: ```kotlin theme={null} portal.sendAsset( chainId = "eip155:11155111", params = SendAssetParams(to = "0x...", amount = "0.01", token = "NATIVE") ).onSuccess { txHash -> println("Transaction sent: $txHash") }.onFailure { e -> when (e) { is PortalException.Transaction.SendAssetArgumentError -> println("Invalid parameters: ${e.message}") is PortalException -> println("Portal error: ${e.message}") else -> println("Unexpected error: ${e.message}") } } ``` ### Branch on the HTTP status code `PortalException.Api.HttpRequestFailed` exposes the status code that produced the failure, so you can separate a transient server-side error from a terminal one without matching on the message string. | Property | Type | Description | | ------------ | ------ | ----------------------------------------------------------------------------------------------- | | `statusCode` | `Int?` | The HTTP status code that produced the failure, or `null` when the SDK captured no status code. | `HttpRequestFailed` only covers status codes that aren't already routed to a more specific exception: 4xx codes of 402 and above, every 5xx, and unrecognized codes. A 400 throws `PortalException.Api.HttpBadRequest` and a 401 throws `PortalException.Api.HttpUnauthorized`, both of which are siblings of `HttpRequestFailed` rather than subclasses. `statusCode` is therefore never `400` or `401` — catch those two types directly. `statusCode` is `null` in two situations, and neither one means "unknown error": * **No HTTP response was received.** A socket timeout, a DNS failure, or a malformed status line never produces a status code. Codes outside the `100..599` range are reported as `null` for the same reason. * **The request returned `200 OK` but the body carried an error envelope.** The failure is real, but the HTTP status was a success, so there is no error code to report. Treat `null` as "there is no status code to branch on" and fall through to your default handling: ```kotlin theme={null} portal.api.getAssets(chainId) .onSuccess { assets -> println("Assets: $assets") } .onFailure { error -> when (error) { is PortalException.Api.HttpBadRequest -> println("Invalid request: ${error.rawBody}") is PortalException.Api.HttpUnauthorized -> println("Unauthorized — check your Portal API key") is PortalException.Api.HttpRequestFailed -> when { error.statusCode == null -> println("No status code: network failure or error envelope") error.statusCode in 500..599 -> println("Transient server error ${error.statusCode} — retry with backoff") else -> println("Request failed with HTTP ${error.statusCode}") } else -> println("Unexpected error: ${error.message}") } } ``` `statusCode` was added in `9.1.0`. On earlier versions the only way to identify the status code was to parse it out of `message`, which is formatted as `" - "`. ## Absent shares versus unreadable shares As of `9.1.0`, having no share on the device is no longer an error. `getShares()` on a `Keychain` instance returns `null` in that case; before `9.1.0` it threw `IllegalArgumentException("No user share found on device storage")`. `getShares()` reads through a fallback chain: the current `shares` key first, then three legacy key formats kept for backward compatibility. An entry that can't be parsed doesn't fail the call — it moves on to the next format in the chain. So an unreadable entry under the current `shares` key resolves to `null` too, as long as no legacy entry is readable either. | Situation | Before `9.1.0` | `9.1.0` and later | | ------------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------- | | No share on the device | throws `IllegalArgumentException` | returns `null` | | Unreadable entry under the current `shares` key, no readable legacy entry | throws `IllegalArgumentException` | returns `null` | | Unreadable entry under the deprecated `PortalMpc.DgkResult` key | throws `IllegalArgumentException` | throws, message `"Unable to parse share from device storage"` | Treat a `null` result as "this device has no usable share" rather than strictly "no share exists". Code that relied on the throw to detect "no wallet on this device" will now take the success path with a `null` value instead of entering its `catch` block, so check the result for `null` explicitly. The `"Unable to parse share from device storage"` message is reserved for the last link in that chain — the misspelled deprecated key from an older SDK version — so in practice you'll only see it on a device mid-migration. A parse failure elsewhere in the chain surfaces as whatever the JSON parser threw, not as an `IllegalArgumentException`. This is what makes `portal.isWalletOnDeviceOrThrow()` return `false` rather than throw when the user has no usable wallet on the current device. See [manage wallet lifecycle states](./manage-wallet-lifecycle-states) for the migration. ## Exception reference ### MPC (`PortalException.Mpc`) | Exception | Description | | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `BackupShareNotFound` | No backup share found for the specified backup method. | | `OrganizationBackupShareNotFound` | No organization backup share found for the specified backup method. | | `WalletNotFound` | No wallet found for the specified backup method. | | `MpcVersionNoLongerSupported` | The MPC operation is not supported on the current MPC version. Upgrade to `v6`. | | `WalletModificationAlreadyInProgress` | Another wallet modification operation (generate, backup, recover) is already running. | | `MpcResultError` | The MPC server returned an error. Exposes `id` (the error identifier string) and `error` (a `PortalError` with `id` and `message`). See [MPC error codes](/resources/error-codes) for the full list of server-side error IDs. | | `UnableToDeriveShareId` | Could not derive a share ID from the generated share. | | `UnableToReadShares` | Failed to read shares from storage. | | `BackendStorageFailed` | Failed to store data on Portal's backend. | | `ClientCipherTextNotFound` | No client cipherText found for the specified backup method during recovery. | | `UnableToReadFromBackupStorage` | Failed to read data from backup storage. | | `UnableToReadFromKeychain` | Failed to read data from keychain storage. | | `UnableToNotifyEjection` | Could not notify Portal of the ejection request. | | `NoPrivateKeysEjected` | No private keys could be ejected. | | `ShareFormattingError` | Error formatting shares during ejection. | | `NoFormattedSharesData` | No data found in the formatted shares response. | | `InstanceNotAvailable` | The MPC instance is not available. | | `BackupMethodNotConfigured` | The specified backup method has not been configured on the Portal instance. See [back up a wallet](./back-up-a-wallet) for configuration steps. | | `UnsupportedCurve` | The specified cryptographic curve is not supported. | | `Eip155WalletNotFound` | No EIP-155 wallet found. Call `createWallet()` first. | ### Storage (`PortalException.Storage`) | Exception | Description | | ---------------------------------- | --------------------------------------------------------------- | | `GoogleDriveWriteFailed` | Failed to write to Google Drive. | | `GoogleDriveVerificationFailed` | Could not verify the file was saved to Google Drive. | | `GoogleDriveFilenameError` | Cannot determine filename without a configured API. | | `GoogleDriveClientDataUnavailable` | Client data is not available for Google Drive operations. | | `GoogleDriveHashesError` | Error retrieving hashes from Google Drive. | | `PasskeySessionIdNotFound` | No session ID found during passkey operations. | | `PasskeyOperationError` | A passkey operation failed. | | `KeychainValidationError` | Keychain validation failed. | | `UserRecoverableGDriveException` | A user-recoverable Google Drive authentication error. | | `StorageAuthError` | Storage authentication failed. | | `PasskeyNotSupported` | Passkeys are not supported on this device. | | `PasskeyStorageNotConfigured` | Passkey storage has not been configured on the Portal instance. | ### Wallet (`PortalException.Wallet`) | Exception | Description | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `WalletNotOnDevice` | No wallet share is available on device storage. The user needs to [recover their wallet](./recover-a-wallet). See [wallet lifecycle states](./manage-wallet-lifecycle-states) for handling this proactively. | | `InvalidWalletState` | The wallet is in an invalid state for the requested operation. | ### Transaction (`PortalException.Transaction`) | Exception | Description | | ---------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `InvalidTransactionResponse` | The response from the transaction was an unexpected type. | | `SendAssetArgumentError` | Invalid arguments passed to `sendAsset` (missing parameters, invalid chain ID format, or unsupported namespace). | ### API (`PortalException.Api`) | Exception | Description | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `HttpBadRequest` | An HTTP request returned 400 Bad Request. Exposes `rawBody`, the unparsed response body. | | `YieldApiBadRequest` | The Yield API returned a 400 Bad Request error. Extends `HttpBadRequest`. | | `HttpRequestFailed` | An HTTP request failed with a non-success status code other than 400 or 401. Exposes `statusCode` — see [branch on the HTTP status code](#branch-on-the-http-status-code). | | `RpcError` | An RPC request failed. Contains `code` and `message` fields. | | `HttpUnauthorized` | An HTTP request returned 401 Unauthorized. Check your API key. | ### Connect (`PortalException.Connect`) | Exception | Description | | ---------------------- | ------------------------------------------------- | | `InvalidRequestMethod` | The WalletConnect request method is invalid. | | `InvalidRequestParams` | The WalletConnect request parameters are invalid. | ### Provider (`PortalException.Provider`) | Exception | Description | | ---------------------------------- | ---------------------------------------------------------- | | `InvalidRequestParams` | The provider request parameters are invalid. | | `NoAddress` | No address was found for the requested chain. | | `NoRpcUrlFoundForChainId` | No RPC URL is configured for the given chain ID. | | `NoSignatureFoundInSignResult` | The signing operation completed but returned no signature. | | `UnsupportedRequestMethod` | The request method is not supported on this chain. | | `NoBindingForSigningApprovalFound` | No signing approval callback is registered. | | `UserDeclinedApproval` | The user declined the transaction approval prompt. | ### Blockchain (`PortalException.Blockchain`) | Exception | Description | | -------------------------------- | ----------------------------------------------------------------------------------------------- | | `InvalidChainId` | The chain ID string is malformed (missing `:` separator). Contains the invalid `chainId` value. | | `NoSupportedCurveForChainId` | No supported cryptographic curve found for the given chain ID. | | `NoSupportedNamespaceForChainId` | No supported namespace found for the given chain ID. | ## Migration from previous versions In previous versions of the Android SDK, errors were thrown as generic `Error`, `Exception`, or flat `PortalException` subclasses like `SendAssetArgumentError` and `PortalHttpUnauthorizedException`. These legacy classes are now deprecated and will be removed in a future major version. ### What changed * All generic `Error(...)` and `Exception(...)` throws have been replaced with specific `PortalException` subclasses. * Flat exception classes (`SendAssetArgumentError`, `PortalHttpUnauthorizedException`, `PasskeyNotSupportedException`, `PasskeyStorageNotConfiguredException`, `MpcError`, `InvalidWalletStateError`, `StorageAuthError`) now extend their corresponding `PortalException` subcategory. Your existing `catch` blocks will still work, but you should migrate to the new types. ### Before and after ```kotlin theme={null} // Before — catching a flat legacy class try { portal.backupWallet() } catch (e: PortalHttpUnauthorizedException) { println("Unauthorized: ${e.message}") } catch (e: Exception) { println("Something went wrong: ${e.message}") } // After — catching the structured exception try { portal.backupWallet() } catch (e: PortalException.Api.HttpUnauthorized) { println("Unauthorized: ${e.message}") } catch (e: PortalException.Mpc) { println("MPC error: ${e.message}") } catch (e: PortalException.Storage) { println("Storage error: ${e.message}") } ``` ### Deprecated classes mapping | Deprecated class | Replace with | | -------------------------------------- | ----------------------------------------------------- | | `SendAssetArgumentError` | `PortalException.Transaction.SendAssetArgumentError` | | `PortalHttpUnauthorizedException` | `PortalException.Api.HttpUnauthorized` | | `PasskeyNotSupportedException` | `PortalException.Storage.PasskeyNotSupported` | | `PasskeyStorageNotConfiguredException` | `PortalException.Storage.PasskeyStorageNotConfigured` | | `MpcError` | `PortalException.Mpc.MpcResultError` | | `InvalidWalletStateError` | `PortalException.Wallet.InvalidWalletState` | | `StorageAuthError` | `PortalException.Storage.StorageAuthError` | # Evaluate a transaction Source: https://docs.portalhq.io/sdks/android/guide/evaluate-a-transaction Before committing to a transaction, it's often useful to simulate its outcome to understand the potential changes and detect any errors preemptively. Portal provides the function **`portal.api.evaluateTransaction`**, which gives you insights into what will happen upon executing the transaction. This function can perform transaction simulation, security validation, or both. Here's how it works: ```kotlin theme={null} suspend fun evaluateTransaction(): Result { // Basic transaction evaluation runCatching { val transaction = EvaluateTransactionParam( to = "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", value = "1000000000000000000", // 1 ETH data = null, maxFeePerGas = "30000000000", maxPriorityFeePerGas = "1500000000", gas = "21000", gasPrice = null ) val evaluation = portal.api.evaluateTransaction( chainId = "eip155:1", transaction = transaction, operationType = ALL ).getOrThrow() // Check validation results evaluation.validation?.let { validation -> println("Security Status: ${validation.status}") if (validation.status != "VALIDATED") { println("Warning: ${validation.description ?: "Unknown issue"}") // Print detected security features validation.features.forEach { feature -> println("Security Feature: ${feature.type}") println("Description: ${feature.description}") } } } // Check simulation results evaluation.simulation?.let { simulation -> println("Simulation Status: ${simulation.status}") // Print asset changes simulation.assetsDiffs.forEach { (token, diffs) -> println("\nToken: $token") diffs.forEach { diff -> println("Incoming transfers:") diff.`in`.forEach { transfer -> println(transfer) } println("Outgoing transfers:") diff.out.forEach { transfer -> println(transfer) } } } } }.onFailure { error -> println("Evaluation failed: $error") return Result.failure(error) } // Contract interaction evaluation runCatching { val transaction = EvaluateTransactionParam( to = "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", value = "0", data = "0xa9059cbb000000000000000000000000742d35cc6634c0532925a3b844bc454e4438f44e0000000000000000000000000000000000000000000000000de0b6b3a7640000", // ERC20 transfer maxFeePerGas = null, maxPriorityFeePerGas = null, gas = "65000", gasPrice = "20000000000" ) // Validate only val evaluation = portal.api.evaluateTransaction( chainId = "eip155:1", transaction = transaction, operationType = VALIDATION ).getOrThrow() evaluation.validation?.let { validationResult -> // Handle potential security issues when (validationResult.status) { "VALIDATED" -> { println("Transaction is safe") } "WARNING" -> { println("Warning: ${validationResult.description ?: "Unknown warning"}") } "BLOCKED" -> { println("Blocked: ${validationResult.reason ?: "Unknown reason"}") } else -> { println("Unknown status: ${validationResult.status}") } } } }.onFailure { error -> println("Validation failed: $error") return Result.failure(error) } return Result.success("") } ``` The `operationType` parameter determines what type of evaluation is performed. It can be one of the following values: * `SIMULATION`: Perform transaction simulation only * `VALIDATION`: Perform security validation only * `ALL`: Perform both validation and simulation ```kotlin theme={null} suspend fun handleSimulateTransaction() { // First, create a transaction. val transaction = SimulateTransactionParam( to = "0xRecipientAddressHere", // The recipient address. value = "0x10DE4A2A", // The value to be sent in Wei. data = null, // Data for the transaction (for contract interactions). maxFeePerGas = null, // Maximum fee per gas. maxPriorityFeePerGas = null, // Maximum priority fee per gas. gas = null, // The gas limit. gasPrice = null // Gas price in Wei. ) // Next, simulate the transaction. val simulatedResult = portal.api.simulateTransaction(transaction) // Finally, handle or display the simulation results. when { simulatedResult.requestError != null -> { print("Request error: ${simulatedResult.requestError.message}") } simulatedResult.error != null -> { print("Transaction will have error: ${simulatedResult.error.message}") } else -> { print("Simulated transaction results: ${simulatedResult.changes}") } } } ``` This function returns a `BlockaidValidateTrxRes` containing: * **simulation**: Transaction simulation results including: * **accountAddress**: Address being analyzed * **accountSummary**: Summary of account state changes * **addressDetails**: Detailed information about involved addresses * **assetsDiffs**: Asset balance changes, containing: * **asset**: Asset information * **in**: Incoming transfers * **out**: Outgoing transfers * **exposures**: Risk exposure analysis * **status**: Simulation status * **totalUsdDiff**: Total USD value change * **totalUsdExposure**: Total USD value at risk * **validation**: Security validation results (when `operationType` is `VALIDATION` or `ALL`) including: * **classification**: Type of potential security issue * **description**: Detailed description of the validation results * **features**: Array of detected security features * **status**: Validation status * **block**: Block number used for evaluation * **chain**: Chain identifier By incorporating transaction simulations, you can provide your users with a preview of the transaction outcomes and preemptively detect and handle errors, ensuring a smoother user experience. *** And now you are evaluating transactions with Portal! 🙌 🚀 Next, we'll explore how to back up the wallet for recovery if the user loses device access. # Upgrading to EIP-7702 Source: https://docs.portalhq.io/sdks/android/guide/evm-account-type Learn how to check wallet account type and upgrade EOA wallets to EIP-7702 using Portal's Android SDK. Portal's Android SDK provides EVM Account Type management through the `portal.evmAccountType` API. This enables checking wallet account types and upgrading EOA wallets to EIP-7702 on supported EVM chains. ## Overview The EVM Account Type functionality allows you to: * **Check wallet status** to determine the current account type * **Get wallet addresses** to retrieve the EOA address and (if applicable) the smart contract address * **Upgrade to EIP-7702** to enable ejecting the Portal wallet's EOA private key ## Prerequisites Before using EVM Account Type operations, ensure you have: * A properly initialized Portal client * An active wallet on a supported network (see [Create a wallet](./create-a-wallet)) * Understanding of [EVM Account Type concepts](/resources/evmAccountType) * You fund the [Upgrade Sponsor address with native tokens for the target chain in the Portal Dashboard](https://app.portalhq.io/upgrade-sponsorship) ## Checking Wallet Status Use `getStatus` to check the current account type of your wallet. The response includes both the account status and metadata about the wallet addresses. ```kotlin theme={null} lifecycleScope.launch { try { val chainId = "eip155:10143" // Monad Testnet val status = portal.evmAccountType.getStatus(chainId) println("Status: ${status.data.status}") println("EOA Address: ${status.metadata.eoaAddress}") status.metadata.smartContractAddress?.let { println("Smart Contract Address: $it") } if (status.data.status == "EIP_155_EOA") { println("Can upgrade to EIP-7702") } else if (status.data.status == "EIP_7702_EOA") { println("Already upgraded") } else { println("Cannot upgrade") } } catch (e: Exception) { println("Failed to get account status: ${e.message}") } } ``` **Response (`EvmAccountTypeResponse`):** * `data.status`: One of `EIP_155_EOA`, `SMART_CONTRACT`, or `EIP_7702_EOA` * `metadata.eoaAddress`: The EOA address associated with the wallet * `metadata.smartContractAddress`: Your smart contract wallet address (nullable, may be `null` for EOA-only accounts) * `metadata.chainId`: The chain identifier *** ## Getting Wallet Addresses Use `getAddresses` to retrieve the EOA and smart contract addresses for your wallet on a given chain. This is a convenience method that returns the EOA and optional smart contract address, equivalent to the address metadata returned by `getStatus`. ```kotlin theme={null} lifecycleScope.launch { try { val chainId = "eip155:10143" // Monad Testnet val addresses = portal.evmAccountType.getAddresses(chainId) println("EOA Address: ${addresses.eoaAddress}") addresses.smartContractAddress?.let { println("Smart Contract Address: $it") } } catch (e: Exception) { println("Failed to get addresses: ${e.message}") } } ``` **Parameters:** * `chainId` (`String`): A CAIP-2 chain identifier (e.g., `"eip155:10143"`) **Response (`EvmAccountTypeAddresses`):** * `eoaAddress` (`String`): The EOA address associated with the wallet * `smartContractAddress` (`String?`): The smart contract wallet address (nullable, `null` for EOA-only accounts) *** ## Upgrading to EIP-7702 Use `upgradeTo7702` to upgrade an EOA wallet to EIP-7702. This method automatically handles the entire upgrade process, including gas subsidization, and returns the transaction hash directly. ```kotlin theme={null} lifecycleScope.launch { try { val chainId = "eip155:10143" // Monad Testnet // Upgrade to EIP-7702 val txHash = portal.evmAccountType.upgradeTo7702(chainId) println("Upgrade done! TX: $txHash") // Verify final status val statusAfter = portal.evmAccountType.getStatus(chainId) println("Final Status: ${statusAfter.data.status}") if (statusAfter.data.status == "EIP_7702_EOA") { println("Successfully upgraded to EIP-7702!") } } catch (e: Exception) { println("Upgrade failed: ${e.message}") } } ``` **What happens during the upgrade:** 1. **Chain validation**: Verifies the chain uses the `eip155` namespace 2. **Status check**: Confirms the wallet is currently an `EIP_155_EOA` 3. **Authorization building**: Creates the EIP-7702 authorization list 4. **Signature**: Signs the authorization hash with your wallet 5. **Transaction submission**: Submits the subsidized upgrade transaction (gas is paid by Portal) 6. **Transaction hash**: Returns the on-chain transaction hash **Response:** The method returns a `String` containing the transaction hash of the submitted upgrade transaction. *** The upgrade process is irreversible per wallet. Once upgraded to EIP-7702, the wallet cannot be reverted to a standard EOA on that chain. *** ## Supported Networks EVM Account Type upgrades are currently available on: * **Monad Testnet** (`eip155:10143`) For a complete list of supported chains, reach out to the Portal team. *** ## Next Steps * Learn about [signing transactions](./sign-a-transaction) * Explore [Portal API methods](./portal-api-methods) * Review [EVM Account Type concepts](/resources/evmAccountType) * Check out [wallet lifecycle management](./manage-wallet-lifecycle-states) # Feature Flags Source: https://docs.portalhq.io/sdks/android/guide/feature-flags Feature flags allow you to enable or disable specific features in the Portal SDK. This provides flexibility in customizing the behavior of the SDK for your application. ### Overview The `FeatureFlags` data class is used to configure feature flags when initializing the Portal SDK. Each flag corresponds to a specific feature or behavior that can be toggled on or off. #### Example Usage: ```kotlin theme={null} import io.portalhq.android.Portal import io.portalhq.android.mpc.data.FeatureFlags // Initialize Portal with custom feature flags val portal = Portal( apiKey = "CLIENT_API_KEY_OR_CLIENT_SESSION_TOKEN", featureFlags = FeatureFlags( useEnclaveMpcApi = true ) ) ``` *** ### Available Feature Flags Below is a list of available feature flags and their functionality. #### 1. `useEnclaveMpcApi` * **Type**: `Boolean?` * **Default**: `null` (disabled) * **Description**: Enables the use of the **Enclave MPC API** for signing transactions. When enabled, MPC operations are executed server-side in a secure AWS Nitro Enclave, ensuring consistent and faster signing times. **How It Works** Executing MPC operations on client devices can lead to inconsistent signing times due to variations in device CPU performance. By enabling the `useEnclaveMpcApi` flag, the client key share is transmitted to a **Trusted Execution Environment (TEE)** hosted in an AWS Nitro Enclave. This ensures: 1. **Encrypted Memory**: All data processed in the enclave is encrypted and inaccessible to anyone, including Portal employees. 2. **Verified Execution**: Users can cryptographically verify that their request was handled in a secure enclave using signed measurements. **Example:** ```kotlin theme={null} import io.portalhq.android.Portal import io.portalhq.android.mpc.data.FeatureFlags // Initialize Portal with the Enclave MPC API enabled val portal = Portal( apiKey = "CLIENT_API_KEY_OR_CLIENT_SESSION_TOKEN", featureFlags = FeatureFlags( useEnclaveMpcApi = true ) ) ``` By setting `useEnclaveMpcApi` to `true`, the Portal instance will use the Enclave MPC API for signing transactions, ensuring faster computation and consistent performance across client devices. *** #### `usePreGeneratedWallet` * **Type**: `Boolean?` * **Default**: `null` (disabled) * **Description**: When enabled, `portal.createWallet()` attempts to claim a pre-generated wallet share instead of running the standard interactive MPC generation. This can make wallet creation faster. You do not need to change how you call `createWallet`. **How It Works** Normally, `createWallet` runs the MPC key generation (DKG) protocol interactively between the device and the MPC servers at the moment it is called. With this flag enabled, the SDK asks the enclave for a share from a pre-computed pool instead. Two separate fallbacks protect that path, and it helps to keep them apart: * **Enclave-side fallback**—if the pool is empty, or the eligibility check can't be completed, the enclave generates the shares on demand and returns them in the same successful response. Your app sees an ordinary success, and the device still skips the DKG protocol. * **SDK-side fallback**—if the request fails with an HTTP 5xx, the SDK falls back to running the standard DKG protocol on the device. This is transparent too: `createWallet` returns the same `Result` it would through the standard flow. Failures that aren't a 5xx are not retried and propagate as a failed `Result`. | Scenario | Behavior | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Flag disabled (default)** | `createWallet` runs the standard interactive DKG protocol, exactly as it does today. | | **Flag enabled, share claimed** | `createWallet` claims a pre-generated share, and wallet creation completes faster than the standard flow. | | **Pool empty or eligibility check unavailable** | Handled inside the enclave, which generates the shares on demand and returns them in a successful response. The SDK sees a success rather than a 5xx, so this never reaches your error handling and never triggers the on-device DKG protocol. | | **Enclave returns HTTP 5xx** | The SDK falls back to the standard interactive DKG protocol on the device. | | **Enclave rejects the claim (HTTP 4xx)** | Returned as a failed `Result` carrying `PortalException.Mpc.MpcResultError` with the enclave's message. The common case is a client that already has a wallet: `Not eligible to claim a pre-generated wallet` when the pre-check catches it, or `Wallet already exists` when the claim is rejected at finalization. These are expected hard failures, not fallback cases. | | **Any other failure** | An auth failure (`PortalException.Api.HttpUnauthorized` on a 401), an `IOException` from the network, or a share that fails to decode are returned as a failed `Result`, since retrying with the standard flow wouldn't resolve them. | Claiming a share does not change the security model: shares are still split between the user's device and Portal, and the pre-generated shares are produced in the same Trusted Execution Environment described in the `useEnclaveMpcApi` section above. **Checking the fallback rule yourself** The policy is driven by a public helper, `Mpc.isRetryableServerError(error: Throwable)`, which returns `true` only for a `PortalException.Api.HttpRequestFailed` carrying a 5xx status. Every other throwable—including other `PortalException.Api` types and `IOException`—returns `false`. It is useful for asserting the classification in your own tests: ```kotlin theme={null} import io.portalhq.android.exceptions.PortalException import io.portalhq.android.mpc.Mpc val serverError = PortalException.Api.HttpRequestFailed("503", statusCode = 503) assert(Mpc.isRetryableServerError(serverError)) // true — the SDK falls back on this ``` Note that by the time an error reaches your `Result`, the SDK has already made the fallback decision, so this helper predicts nothing about an error you receive from `onFailure`. See [Error handling](./error-handling) for the full `PortalException` hierarchy. **When to enable** Enable `usePreGeneratedWallet` when you want faster wallet creation without changing your integration. It helps most on lower-end devices, where running the DKG protocol on the handset is slowest and least consistent. The resulting wallet is identical to one created through the standard flow. **Limitations** * This is a performance optimization only; it doesn't change the API surface, the resulting wallet, or how backup and recovery work. * An empty pool isn't something your app has to handle: the enclave covers it by generating on demand, so it never reaches the SDK as an error. * SDK-side fallback to the on-device protocol occurs on an HTTP 5xx response from the enclave only. A 4xx rejection (such as `Wallet already exists`), an unauthorized API key, and network failures propagate normally—so a misconfigured API key produces a failed wallet creation rather than a slow one. Keep your existing error handling around `createWallet`. **Example:** ```kotlin theme={null} import android.os.Bundle import android.util.Log import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import io.portalhq.android.Portal import io.portalhq.android.mpc.data.FeatureFlags import kotlinx.coroutines.launch class MainActivity : AppCompatActivity() { private lateinit var portal: Portal override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Initialize Portal with pre-generated wallets enabled portal = Portal( apiKey = "CLIENT_API_KEY_OR_CLIENT_SESSION_TOKEN", featureFlags = FeatureFlags( usePreGeneratedWallet = true ) ) lifecycleScope.launch { portal.createWallet() .onSuccess { walletAddresses -> Log.d("[PortalEx]", "✅ Generated address successfully! $walletAddresses") } .onFailure { error -> // A 5xx from the enclave would have fallen back to the standard flow, so // reaching this point means the failure was not retryable — for example the // client already has a wallet, which the enclave rejects with a 4xx. Log.d("[PortalEx]", "Error generating share: ${error.stackTraceToString()}") } } } } ``` Every parameter on `FeatureFlags` is optional and defaults to `null`, so you can enable `usePreGeneratedWallet` on its own as shown above, or alongside any other flag. **Types** Claiming a share calls the Enclave MPC API at `POST https://{enclaveApiHost}/v1/generate`, where `enclaveApiHost` is a `Portal` constructor parameter that defaults to `mpc-client.portalhq.io`. The request and response types are public so you can build mocks and tests against them—most integrations never reference them directly, since `createWallet` handles the response for you. ```kotlin theme={null} data class GenerateWithEnclaveApiRequest( @SerializedName("usePreGenerated") val usePreGenerated: Boolean, @SerializedName("metadataStr") val metadataStr: String, ) data class GenerateWithEnclaveApiResponse( @SerializedName("SECP256K1") val secp256k1: ShareData?, @SerializedName("ED25519") val ed25519: ShareData?, @SerializedName("error") val error: PortalError? = null, ) // ShareData.share is the base64-encoded serialized MPC share. data class ShareData( val id: String, val share: String, ) ``` Both curve fields are nullable, so a response that decodes successfully can still be missing a share. # Getting Started Source: https://docs.portalhq.io/sdks/android/guide/getting-started Follow this guide to integrate Portal in your Android app. Portal provides MPC **wallets** and dApp **connections** for organizations and their users. To integrate Portal, an organization adds a **client library** to their mobile app and a few **server API endpoints**. ## Installation Update the dependencies in your `build.gradle` to include the `portal-android` dependency ```groovy theme={null} // In your app/build.gradle file ... dependencies { ... implementation "io.portalhq.android:portal-android:X.X.X" } ``` ## Initializing Portal After you update your `build.gradle` file, you must sync gradle and you're ready to import Portal into an Activity of your choice. With the `Portal Android` dependency now installed, we can now create an instance of the `Portal` class. Below is an example of how you can do this. In this example, we're using your app's `MainActivity`. ```kotlin theme={null} package io.portal.android.app import io.portal.android.Portal import io.portal.android.storage.cloud.google.GoogleStorage import io.portal.android.storage.mobile.Keychain import io.portalhq.android.mpc.data.FeatureFlags class MainActivity : AppCompatActivity() { lateinit val portal: Portal override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Initialize your Portal instance. portal = Portal( // A Portal client API key. You can obtain one from Portal's REST API. apiKey = CLIENT_API_KEY, // A map of chainIDs to Gateway URLs (e.g. Infura, Alchemy, etc.) rpcConfig = mapOf("eip155:10143" to YOUR_GATEWAY_URL), // A boolean to auto-approve transactions. autoApprove = true, // Optional: Enable feature flags, such as faster wallet creation featureFlags = FeatureFlags(usePreGeneratedWallet = true), //Optional: Only provide Eth chain id here if you are upgrading from V3 otherwise skip this. This is needed for some legacy code legacyEthChainId = 10143 ) // Configure Backup with GDrive portal.configureGoogleStorage( GDriveConfiguration( clientId = YOUR_GDRIVE_CLIENT_ID, // Your Google client id with GDrive access signOutAfterUse = true // Signout Google after backup/recover operation, gDriveBackupOption = GDriveBackupOption.AppDataFolder // this mode specifies where the backup key is stored in GDrive. Will be explained later in GDrive backup guides ) ) } } ``` ```kotlin theme={null} package io.portalhq.android.app import io.portalhq.android.Portal import io.portalhq.android.storage.cloud.google.GoogleStorage import io.portalhq.android.storage.mobile.Keychain class MainActivity : AppCompatActivity() { lateinit val portal: Portal override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // 👇 Don't forget to replace below with your own Google Client ID. val gDrive = GoogleStorage(YOUR_GOOGLE_CLIENT_ID, signoutAfterUse = true) val backupOptions = BackupOptions(gdrive = gDrive) val keychain = Keychain(applicationContext) // Initialize the Portal instance portal = Portal( // A Portal client API key. You can obtain one from Portal's REST API. apiKey = CLIENT_API_KEY, // Provide this if you want to implement backup yourself backup = null, // The EVM network chain ID. chainId = 10143, // An instance of Portal's Keychain. keychain = keychain, // A map of chainIDs to Gateway URLs (e.g. Infura, Alchemy, etc.) gatewayConfig = mapOf(10143 to YOUR_GATEWAY_URL), // Provide configuration of backup options you want to use with Portal backupOptions = backupOptions ) } } ``` For faster wallet generation, enable the [`usePreGeneratedWallet`](./feature-flags#usepregeneratedwallet) feature flag here—no other code changes are needed. Now that we have our Portal instance, the next step is to generate a wallet. Let's create one! If you are using [Client Session Tokens (CSTs)](../../../resources/authentication-and-api-keys), this hint is for you. When your user's CST expires, all Portal SDKs will throw an error on the next MPC Operation the user makes (e.g. creating a wallet, backing up a wallet, recovering a wallet, or signing). For Android, the SDK will throw `PortalHttpUnauthorizedException` which you can use as an indicator to refresh your CST. # Enhance your wallets' security with Hypernative Source: https://docs.portalhq.io/sdks/android/guide/hypernative Learn how to integrate real-time security scanning for transactions, addresses, tokens, NFTs, and URLs using Portal's Android SDK with Hypernative. Portal's Android SDK provides comprehensive security scanning capabilities through the `portal.security.hypernative` API. This integration allows you to detect malicious transactions, flagged addresses, compromised tokens, and suspicious URLs before users interact with them. ## Overview The Hypernative integration enables you to: * **Scan transactions** before signing or submission (EVM, EIP-712, Solana) * **Scan addresses** for known malicious actors or compromised contracts * **Validate tokens** to detect scams, honeypots, or security risks * **Check NFTs** for fraudulent collections or suspicious activity * **Verify URLs** to prevent phishing attacks ## Prerequisites Before using Hypernative security scanning, ensure you have: * A properly initialized Portal client * Hypernative integration enabled in your Portal Dashboard (see [Hypernative Integration](../../../integrations/Security/hypernative)) ## Scanning EVM Transactions Use `scanEVMTx` to analyze standard Ethereum transactions before signing or sending. This method scans EVM transactions for malicious contract interactions, suspicious token approvals, and other security risks. EVM transactions are standard Ethereum transactions that include a chain ID to prevent replay attacks across different networks. ```kotlin theme={null} lifecycleScope.launch { val transaction = ScanEVMTransaction( chain = "eip155:1", fromAddress = "0x7C01728004d3F2370C1BBC36a4Ad680fE6FE8729", toAddress = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", input = "0x095ea7b300000000000000000000000066ba61be3bab35c0c00038f335850a390b086fe300000000000000000000000000000000000000000fffffffffffffffffffffff", value = 0, nonce = 2340, gas = 3000000, gasPrice = 3000000 ) val request = ScanEVMRequest(transaction = transaction) val result = portal.security.hypernative.scanEVMTx(request) result.onSuccess { response -> val rawResponse = response.data?.rawResponse println("Success: ${rawResponse?.success}") rawResponse?.data?.let { data -> println("Recommendation: ${data.recommendation}") println("Assessment ID: ${data.assessmentId}") data.findings?.forEach { finding -> println("Finding: ${finding.title} (${finding.severity})") } } }.onFailure { error -> println("Error scanning EVM transaction: ${error.message}") } } ``` *** ## Scanning EIP-712 Typed Messages Use `scanEip712Tx` to analyze typed structured data before signing. This method is critical for detecting malicious permit signatures, phishing attempts, and unauthorized token approvals. EIP-712 defines a standard for hashing and signing typed structured data, commonly used for gasless approvals and off-chain signatures. ```kotlin theme={null} lifecycleScope.launch { val types = mapOf( "EIP712Domain" to listOf( ScanEip712TypeProperty("name", "string"), ScanEip712TypeProperty("version", "string"), ScanEip712TypeProperty("chainId", "uint256"), ScanEip712TypeProperty("verifyingContract", "address") ), "Permit" to listOf( ScanEip712TypeProperty("owner", "address"), ScanEip712TypeProperty("spender", "address"), ScanEip712TypeProperty("value", "uint256"), ScanEip712TypeProperty("nonce", "uint256"), ScanEip712TypeProperty("deadline", "uint256") ) ) val domain = ScanEip712Domain( name = "MyToken", version = "1", chainId = "eip155:1", verifyingContract = "0xa0b86991c6218b36c1d19d4a2e9Eb0cE3606eB48" ) val message = mapOf( "owner" to "0x7b1363f33b86d16ef7c8d03d11f4394a37d95c36", "spender" to "0x67beb4dd770a9c2cbc7133ba428b9eecdcf09186", "value" to 3000, "nonce" to 0, "deadline" to 50000000000L ) val typedData = ScanEip712TypedData( primaryType = "Permit", types = types, domain = domain, message = message ) val request = ScanEip712Request( walletAddress = "0x7b1363f33b86d16ef7c8d03d11f4394a37d95c36", chainId = "eip155:1", eip712Message = typedData ) val result = portal.security.hypernative.scanEip712Tx(request) result.onSuccess { response -> val rawResponse = response.data?.rawResponse println("Success: ${rawResponse?.success}") rawResponse?.data?.let { data -> println("Recommendation: ${data.recommendation}") println("Assessment ID: ${data.assessmentId}") } }.onFailure { error -> println("Error scanning EIP-712 transaction: ${error.message}") } } ``` *** ## Scanning Solana Transactions Use `scanSolanaTx` to analyze Solana transactions before signing. This method detects malicious program invocations, suspicious token transfers, and other Solana-specific security risks. ```kotlin theme={null} lifecycleScope.launch { val transaction = ScanSolanaTransaction( message = null, signatures = null, rawTransaction = "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQADCQkVR3SiiKbW0l4c3NBsEn6+zn1o0YsyypPwN0GUhg4K5HK0Tb5GckDLYW+MsovQASt5EZ3bSH3nluRJAE69H61w0BRUDTrpYQcXosUun6/z2BROkRoH/1bL7KLU9s4lCav6k3ZZgV6qeZFwu4pu89WoIGaqUxG4C93XwVmmDy81v8qBaCSP4/UZfdo3q1bud/W+ixymkH8IMe0laQZYrSx4Uhyxec67hYm1VqLV7JTSSYaC/fm7KvWtZOSRzEFT2gMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAT4tlY/P4mFG1wDJl0ektVggHiZf73lTlHBVJ3fK0nDoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANG5fPtlMEOI/eXV7aPDlpcdLUKm8L3VoW6k/oJlCNLaBQYABQLARQQABgAJAwYAAAAAAAAABzwACQoLCwgyMzQMNQ0ONjcPEDg5EgETFBUWOhEXGBkaGxwdHh8gISI7IyQlJicCKCkqKywtAwQuLzAxPBFVCg8JAQcHBgYBAAAAAwHwCgYBExUbBgICAAAPAwIAAAYBISMoEQQBGQAPAwIAAAYBLjA2DwMCAAAGAgIAAAAIBgYICAADAQkGCQUFBgACBQAEBwEAAAgCAAUMAgAAADwaAAAAAAAABgAFBGDMBQAEPPm21Wu6wrmHu23/ZFNIumpp+ADooZjd4JQgvjnBxkUJAgEDBqWqCgmmCAUIBwu1tp+gcP/+Ri3C1tRXUbPdgqo6rVsj/qnqC959wTdC/mRARysLz9HS09TW19jZ2tsC1QYsNrdxMcm5Nq5FXZrM0IXpEA+ApFa+pz/JvkLz0+2vnwuztLW2t7i5uru8vgAPvBv8VUeRwDy9yD1NHIH5Ji6ZA+zrmpHejKOz4MP8SwrKy8zNzs/S09TVAdY=", version = "0" ) val request = ScanSolanaRequest( transaction = transaction, showFullFindings = true ) val result = portal.security.hypernative.scanSolanaTx(request) result.onSuccess { response -> val rawResponse = response.data?.rawResponse println("Success: ${rawResponse?.success}") rawResponse?.data?.let { data -> println("Recommendation: ${data.recommendation}") println("Assessment ID: ${data.assessmentId}") } }.onFailure { error -> println("Error scanning Solana transaction: ${error.message}") } } ``` *** ## Scanning Addresses Use `scanAddresses` to check multiple addresses for known security risks. This method identifies malicious contracts, compromised wallets, sanctioned addresses, and other flagged entities. ```kotlin theme={null} lifecycleScope.launch { val request = ScanAddressesRequest( addresses = listOf( "0x31c05d73f2333b5a176cfdbb7c5ef96ec7bb04ac", "0x2753a0d37a2ad09be3ccc0afcb650bea8ea57a8f" ) ) val result = portal.security.hypernative.scanAddresses(request) result.onSuccess { response -> response.data?.rawResponse?.forEach { item -> println("Address: ${item.address}") println("Recommendation: ${item.recommendation}") println("Severity: ${item.severity}") item.flags?.forEach { flag -> println("Flag: ${flag.title} (${flag.severity})") } } }.onFailure { error -> println("Error scanning addresses: ${error.message}") } } ``` *** ## Scanning NFTs Use `scanNfts` to validate NFT collections before displaying or allowing interactions. This method detects fraudulent collections, compromised contracts, and suspicious NFT activity. ```kotlin theme={null} lifecycleScope.launch { val request = ScanNftsRequest( nfts = listOf( ScanNftsRequestItem( address = "0x5C1B9caA8492585182eD994633e76d744A876548", evmChainId = "eip155:1" ), ScanNftsRequestItem( address = "0xC2e0cA5FE0b9AbE1B86f3cC0b865448908D20A16", evmChainId = "eip155:1" ) ) ) val result = portal.security.hypernative.scanNfts(request) result.onSuccess { response -> response.data?.rawResponse?.data?.nfts?.forEach { nft -> println("NFT: ${nft.address}") println("Accept: ${nft.accept}") println("Chain: ${nft.evmChainId ?: nft.chain}") } }.onFailure { error -> println("Error scanning NFTs: ${error.message}") } } ``` *** ## Scanning Tokens Use `scanTokens` to validate ERC-20 tokens before allowing swaps, transfers, or approvals. This method detects honeypots, scam tokens, and compromised token contracts. ```kotlin theme={null} lifecycleScope.launch { val request = ScanTokensRequest( tokens = listOf( ScanTokensRequestItem( address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC evmChainId = "eip155:1" ) ) ) val result = portal.security.hypernative.scanTokens(request) result.onSuccess { response -> response.data?.rawResponse?.data?.tokens?.forEach { token -> println("Token: ${token.address}") println("Chain: ${token.evmChainId ?: token.chain}") println("Reputation: ${token.reputation?.recommendation ?: "N/A"}") } }.onFailure { error -> println("Error scanning tokens: ${error.message}") } } ``` *** ## Scanning URLs Use `scanURL` to detect phishing sites and malicious domains before users navigate to them. This method is critical for protecting users from social engineering attacks. ```kotlin theme={null} lifecycleScope.launch { val request = ScanUrlRequest(url = "curve.fi") val result = portal.security.hypernative.scanURL(request) result.onSuccess { response -> val isMalicious = response.data?.rawResponse?.data?.isMalicious ?: false val status = if (isMalicious) "⚠️ MALICIOUS" else "✅ SAFE" println("URL: curve.fi") println("Status: $status") println("Is Malicious: $isMalicious") }.onFailure { error -> println("Error scanning URL: ${error.message}") } } ``` *** ## Next Steps * Learn about [signing transactions](./sign-a-transaction) * Explore [transaction evaluation](./evaluate-a-transaction) * Review [Portal API methods](./portal-api-methods) * Check out the [Hypernative Integration setup](../../../integrations/Security/hypernative) # Bridge & Swap with Li.Fi Source: https://docs.portalhq.io/sdks/android/guide/lifi Learn how to bridge and swap tokens across multiple chains using Portal's Android SDK with Li.Fi integration. Portal's Android SDK provides comprehensive cross-chain bridging and swapping capabilities through the `portal.trading.lifi` API. This guide covers getting quotes, finding routes, executing swaps and bridges, and tracking transaction status. ## Overview The Li.Fi functionality allows you to: * **Get quotes** for bridging or swapping tokens across chains * **Find routes** to discover the best paths for your cross-chain transfers * **Execute swaps and bridges** by signing and submitting transactions * **Track transaction status** for cross-chain transfers ## Prerequisites Before using Li.Fi operations, ensure you have: * A properly initialized Portal client * An active wallet with the required token(s) on the source network (see [Create a wallet](./create-a-wallet)) * Li.Fi integration enabled in your Portal Dashboard (see [Li.Fi Integration](../../../integrations/Trading/lifi)) ## High-Level Methods `tradeAsset` runs the entire bridge or swap in one call. `pollStatus` exposes the same Li.Fi status poller `tradeAsset` uses internally, for manual flows where you already have a transaction hash. If you only need to move tokens, use `tradeAsset`. Reach for the [low-level methods](#low-level-methods) when you need to inspect routes before committing, run your own signing, or drive a custom UI. Both return `Result` like every other method on this page — handle them with `onSuccess` / `onFailure`, not `try/catch`. ### tradeAsset Runs the end-to-end Li.Fi flow: 1. Discover routes (`getRoutes`) 2. Select a route (`routeIndex`, default `0`) 3. Build each step (`getRouteStep`) 4. Sign and broadcast that step's transaction 5. Wait for on-chain confirmation of that step 6. Poll Li.Fi status until the step reaches a terminal state 7. Continue to the next step Steps execute **sequentially**, never in parallel. Signing and confirmation for each step happen on that step's own chain, which the SDK resolves from the step itself — so a multi-chain route signs on each chain in turn without you managing it. **Confirmation is strict.** Every step must confirm on-chain before the next begins. `waitForConfirmation` must return `true`; anything else aborts the whole trade and yields a failed `Result`. There is no optimistic fallback. ### Signature ```kotlin theme={null} open suspend fun tradeAsset(params: LifiTradeAssetParams): Result ``` **Essential parameters** | Parameter | Type | Required | Description | | ------------- | ---------------------- | --------------- | ------------------------------------------------------------------------------------------------- | | `fromChain` | `String` | Yes | Source chain. Use CAIP-2 (`"eip155:8453"`), as everywhere else in this guide. | | `toChain` | `String` | Yes | Destination chain, same format. | | `fromToken` | `String` | Yes | Source token contract address or symbol. | | `toToken` | `String` | Yes | Destination token contract address or symbol. | | `amount` | `String` | Yes | Amount in the token's base units, as an integer string (for example wei for an 18-decimal token). | | `fromAddress` | `String?` | No, but pass it | Sending wallet address. See the note below. | | `toAddress` | `String?` | No | Receiving wallet address. Falls back to `fromAddress` when omitted. | | `routeIndex` | `Int?` | No | Which discovered route to execute. Default `0`. | | `onProgress` | `LifiProgressHandler?` | No | Fired at each stage. See [Progress lifecycle](#progress-lifecycle). | `fromAddress` is nullable in the type system but the SDK **does not fill it in for you** — it is forwarded to Li.Fi exactly as given. Omitting it means routes are quoted without a sender, while the transaction is still signed by your Portal wallet, so the quote may not match what actually executes. Pass it explicitly, resolving it from the chain you are trading on with `portal.getAddress(fromChain)`. **Configuring the signer and confirmation** Unlike the React Native and Web SDKs, `tradeAsset` takes **no second options argument**. The signing and confirmation hooks are injected once, when the `Lifi` instance is constructed: ```kotlin theme={null} open class Lifi( private val api: LifiTradingApi, private val signAndSendTransaction: LifiSignAndSendTransaction? = null, private val waitForConfirmation: LifiWaitForConfirmation? = null, private val stepPollOptions: LifiPollStatusOptions = LifiPollStatusOptions( everyMs = 10_000L, initialDelayMs = 10_000L, timeoutMs = 600_000L ) ) ``` `Portal` wires both automatically — `signAndSendTransaction` via `eth_sendTransaction`, and `waitForConfirmation` via an internal receipt poller that retries up to 30 times — so `portal.trading.lifi.tradeAsset(params)` works with no setup. The two callbacks are: ```kotlin theme={null} typealias LifiSignAndSendTransaction = suspend (transaction: EthTransactionParam, chainId: String) -> String typealias LifiWaitForConfirmation = suspend (txHash: String, chainId: String) -> Boolean ``` `waitForConfirmation` **must return `true` for a confirmed transaction.** Both failure modes abort the trade, but they surface differently: * **Returning `false`** fails the `Result` with `LifiTradeAssetException.TransactionConfirmationFailed(txHash)`. * **Throwing** propagates through `tradeAsset`'s catch-all and fails the `Result` with **the original exception**, not `TransactionConfirmationFailed`. That distinction matters for the closure `Portal` wires in: its receipt poller returns `false` for a reverted or never-confirmed transaction, but it **throws** when the RPC calls themselves keep failing — a bad chain ID, an unreachable RPC, an auth error. An `onFailure` that only matches `TransactionConfirmationFailed` will silently miss that whole class of failure, so always keep an `else` branch that surfaces `error` as-is. **Overriding the defaults.** `portal.trading` is built lazily by `Portal` with its own closures already supplied, so the instance at `portal.trading.lifi` cannot be reconfigured after the fact. To use different behavior, build your own instance and call `tradeAsset` on that instead. `Trading` accepts the two callbacks and constructs its `Lifi` internally — it does not take a `Lifi`: ```kotlin theme={null} class Trading( private val api: Api, signAndSendTransaction: LifiSignAndSendTransaction? = null, waitForConfirmation: LifiWaitForConfirmation? = null ) // Custom callbacks, default step polling val trading = Trading( api = portal.api, signAndSendTransaction = { transaction, chainId -> mySigner.signAndSend(transaction, chainId) }, waitForConfirmation = { txHash, chainId -> myConfirmer.await(txHash, chainId) } ) val result = trading.lifi.tradeAsset(params) ``` Construct `Lifi` directly when you also need to change the per-step polling, which `Trading` does not expose: ```kotlin theme={null} val lifi = Lifi( api = portal.api.lifi, signAndSendTransaction = { transaction, chainId -> mySigner.signAndSend(transaction, chainId) }, waitForConfirmation = { txHash, chainId -> myConfirmer.await(txHash, chainId) }, stepPollOptions = LifiPollStatusOptions(everyMs = 5_000L, initialDelayMs = 0L, timeoutMs = 300_000L) ) val result = lifi.tradeAsset(params) ``` Because `Trading` does not pass `stepPollOptions` through, the per-step Li.Fi polling inside `tradeAsset` always uses the `Lifi` constructor default — a 10-second initial delay, 10-second interval, 10-minute timeout. That default differs from the standalone `pollStatus` default, which has **no** initial delay. `Lifi`, `LifiTradingApi`, and their methods are `open` as of 9.1.0, so you can subclass them to stub Li.Fi in tests. **Return value** | Field | Type | Description | | -------- | ---------------- | --------------------------------------------------------------------------- | | `hashes` | `List` | One transaction hash per executed step, in execution order. | | `steps` | `List` | The enriched steps that were executed, with `transactionRequest` populated. | | `route` | `LifiRoute` | The route that was selected and executed. | ### Example (progress reporting) ```kotlin theme={null} import io.portalhq.android.api.data.lifi.LifiTradeAssetParams import io.portalhq.android.api.data.lifi.LifiTradeAssetProgressStatus lifecycleScope.launch { val address = portal.getAddress("eip155:8453") ?: return@launch portal.trading.lifi.tradeAsset( LifiTradeAssetParams( fromChain = "eip155:8453", toChain = "eip155:42161", fromToken = "ETH", toToken = "USDC", amount = "100000000000000", // 0.0001 ETH (in wei) fromAddress = address, onProgress = { status, data -> when (status) { LifiTradeAssetProgressStatus.SIGNING -> Log.i("Portal", "Signing step ${(data.stepIndex ?: 0) + 1} of ${data.totalSteps ?: 0}") LifiTradeAssetProgressStatus.SUBMITTED -> Log.i("Portal", "Submitted: ${data.txHash}") LifiTradeAssetProgressStatus.COMPLETE -> Log.i("Portal", "Trade complete") LifiTradeAssetProgressStatus.FAILED -> Log.e("Portal", "Failed: ${data.errorMessage}") else -> Unit } } ) ).onSuccess { result -> Log.i("Portal", "Hashes: ${result.hashes}") Log.i("Portal", "Executed steps: ${result.steps.size}") }.onFailure { error -> when (error) { is LifiTradeAssetException.NoRoutesFound -> Log.e("Portal", "No route available for this pair") is LifiTradeAssetException.TransactionConfirmationFailed -> // Reverted, or never confirmed within the retry budget — check the hash on-chain. Log.e("Portal", "Not confirmed: ${error.message}", error) else -> // Catches thrown confirmation errors (persistent RPC failures) too. Log.e("Portal", "tradeAsset failed: ${error.message}", error) } } } ``` ### Example (minimal) ```kotlin theme={null} lifecycleScope.launch { val address = portal.getAddress("eip155:8453") ?: return@launch portal.trading.lifi.tradeAsset( LifiTradeAssetParams( fromChain = "eip155:8453", toChain = "eip155:42161", fromToken = "ETH", toToken = "USDC", amount = "100000000000000", fromAddress = address ) ).onSuccess { result -> Log.i("Portal", "Hashes: ${result.hashes}") }.onFailure { error -> Log.e("Portal", "tradeAsset failed", error) } } ``` ### Errors Failures arrive inside the returned `Result` as a `LifiTradeAssetException`: | Case | When | | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `MissingSigner` | No signing closure on the `Lifi` instance. Cannot occur on `portal.trading.lifi`. | | `MissingConfirmation` | No confirmation closure on the `Lifi` instance. Cannot occur on `portal.trading.lifi`. | | `ApiError(detail)` | Li.Fi returned an error payload while fetching routes, resolving a step, or polling status. `detail` carries the API message. | | `NoRoutesFound` | Li.Fi returned no routes for the requested trade. | | `RouteIndexOutOfBounds` | `routeIndex` is negative or beyond the number of discovered routes. | | `RouteHasNoSteps` | The selected route contains no steps. | | `MissingTransactionRequest` | A step came back without a transaction request to sign. | | `InvalidTransactionRequest` | A step's transaction request was missing required fields, or held a value that could not be parsed into a valid RPC hex quantity. | | `TransactionConfirmationFailed(txHash)` | The confirmation closure returned `false`. Not necessarily a revert — see the note below. | | `LifiTransferFailed(detail)` | Li.Fi reported a `FAILED` terminal state. | | `PollTimeout(cause)` | Status polling exceeded the timeout. `cause` carries the most recent transient error, when there was one. | | `PollFailed(cause)` | Polling hit `maxConsecutiveErrors` consecutive failures and stopped early. `cause` carries the triggering error. | `MissingSigner`, `MissingConfirmation`, `NoRoutesFound`, `RouteIndexOutOfBounds`, `RouteHasNoSteps`, `MissingTransactionRequest`, and `InvalidTransactionRequest` are `object` singletons — match them with `is`. The rest are classes carrying detail. **`TransactionConfirmationFailed` is not proof the transaction failed on-chain.** Portal's receipt poller returns `false` in two different situations, and Android maps both onto this one case: * The receipt came back reverted (`status: 0x0`) — the transaction genuinely failed. * The retries ran out before any receipt appeared — the transaction may still be **pending** and could yet confirm. Treat it as "not confirmed", not "failed" — re-check the hash on-chain rather than reporting a definitive failure. Note that `txHash` is a constructor parameter, not an exposed property: it is interpolated into `error.message`, so capture the hash from the `SUBMITTED` progress event if you need it programmatically. iOS keeps these two outcomes apart as `transactionConfirmationFailed` and `transactionConfirmationTimedOut`, so cross-platform code cannot assume both SDKs report an unconfirmed transaction the same way. Cancelling the enclosing coroutine throws `CancellationException` rather than returning a failed `Result`, and **no `FAILED` progress event is emitted**. This preserves structured concurrency, but it means a UI that only dismisses its progress state on `FAILED` or `COMPLETE` will hang on cancellation — handle `CancellationException` separately. ### Progress lifecycle `onProgress` receives a `LifiTradeAssetProgressStatus` and a `LifiTradeAssetProgressData`. Every field on the data class is nullable; which ones are populated depends on the stage: | Status | `value` | Populated data | | ----------------- | ----------------- | ------------------------------------------------ | | `FETCHING_ROUTES` | `fetching_routes` | — | | `ROUTE_SELECTED` | `route_selected` | `routeIndex`, `route`, `totalSteps` | | `PREPARING_STEP` | `preparing_step` | `routeIndex`, `stepIndex`, `totalSteps`, `route` | | `SIGNING` | `signing` | above, plus `step` | | `SUBMITTED` | `submitted` | above, plus `txHash` | | `CONFIRMING` | `confirming` | above, plus `txHash` | | `LIFI_PENDING` | `lifi_pending` | above, plus `lifiStatus` on later emissions | | `STEP_DONE` | `step_done` | above | | `COMPLETE` | `complete` | `route`, `totalSteps` | | `FAILED` | `failed` | `errorMessage` | `txHash` is `null` until `SUBMITTED`. `errorMessage` is only ever set on `FAILED`. ### pollStatus Polls Li.Fi for the status of a transfer until it reaches a terminal state. Use it when you have submitted a transaction yourself and want the same polling behavior `tradeAsset` uses internally. ```kotlin theme={null} open suspend fun pollStatus( request: LifiStatusRequest, onUpdate: ((LifiStatusRawResponse) -> Boolean)? = null, options: LifiPollStatusOptions = LifiPollStatusOptions() ): Result ``` Both `onUpdate` and `options` are defaulted, so `pollStatus(request)` alone is valid. Returning `false` from `onUpdate` stops polling early and succeeds with the last status received — it is not an error. Returning `true` continues. ```kotlin theme={null} lifecycleScope.launch { portal.trading.lifi.pollStatus( request = LifiStatusRequest( txHash = txHash, fromChain = "eip155:8453", toChain = "eip155:42161" ), onUpdate = { update -> Log.i("Portal", "Status: ${update.status}") true // return false to stop polling early } ).onSuccess { final -> Log.i("Portal", "Final status: ${final.status}") }.onFailure { error -> Log.e("Portal", "pollStatus failed", error) } } ``` ### pollStatus options | Option | Type | Default | Description | | ---------------------- | ------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `everyMs` | `Long` | `10_000L` | Interval between polls, in milliseconds. | | `initialDelayMs` | `Long` | `0L` | Delay before the first poll, in milliseconds. Note the `Lifi` constructor's `stepPollOptions` default uses `10_000L` instead. | | `timeoutMs` | `Long` | `600_000L` | Overall polling timeout. Exceeding it fails with `PollTimeout`. | | `maxConsecutiveErrors` | `Int` | `5` | Consecutive transient errors tolerated before failing fast with `PollFailed`. Stops a persistent hard error — an auth failure, say — from being retried silently until the timeout elapses. | ```kotlin theme={null} portal.trading.lifi.pollStatus( request = LifiStatusRequest(txHash = txHash, fromChain = "eip155:8453", toChain = "eip155:42161"), options = LifiPollStatusOptions( everyMs = 5_000L, initialDelayMs = 10_000L, timeoutMs = 300_000L, maxConsecutiveErrors = 3 ) ) ``` *** ## Low-Level Methods The rest of this guide covers the individual Li.Fi methods. Use them when you need control over route selection, signing, or status tracking that `tradeAsset` does not expose. ## Getting a Quote Use the `getQuote` method to get a quote for bridging or swapping tokens across chains. ```kotlin theme={null} lifecycleScope.launch { val address = portal.getAddress("eip155:1") ?: return@launch val request = LifiQuoteRequest( fromChain = "eip155:8453", // Base Mainnet toChain = "eip155:42161", // Arbitrum fromToken = "ETH", toToken = "USDC", fromAddress = address, fromAmount = "100000000000000" // 0.0001 ETH (in wei) ) val response = portal.trading.lifi.getQuote(request) response.onSuccess { quoteResponse -> val rawResponse = quoteResponse.data?.rawResponse if (rawResponse != null) { // Process quote response rawResponse.estimate?.let { estimate -> Log.i("Portal", "From amount: ${estimate.fromAmount}") Log.i("Portal", "To amount: ${estimate.toAmount}") Log.i("Portal", "Execution duration: ${estimate.executionDuration}s") } // Sign and submit the transaction if transactionRequest is available rawResponse.transactionRequest?.let { transactionRequest -> executeTransaction(transactionRequest, request.fromChain) } } }.onFailure { error -> Log.e("Portal", "Error getting quote: ${error.message}") } } ``` The response includes a `transactionRequest` object with the transaction details you'll need to sign and submit. ## Finding Routes Use the `getRoutes` method to discover available routes for your cross-chain transfer. ```kotlin theme={null} lifecycleScope.launch { val address = portal.getAddress("eip155:1") ?: return@launch val request = LifiRoutesRequest( fromChainId = "eip155:8453", // Base Mainnet fromAmount = "100000000000000", // 0.0001 ETH (in wei) fromTokenAddress = "ETH", toChainId = "eip155:42161", // Arbitrum toTokenAddress = "USDC", fromAddress = address ) val response = portal.trading.lifi.getRoutes(request) response.onSuccess { routesResponse -> val rawResponse = routesResponse.data?.rawResponse if (rawResponse != null) { val routes = rawResponse.routes // Find recommended route val recommendedRoute = routes.firstOrNull { route -> route.tags?.contains("RECOMMENDED") == true } ?: routes.firstOrNull() recommendedRoute?.let { route -> Log.i("Portal", "Selected route: ${route.id}") Log.i("Portal", "Steps: ${route.steps.size}") Log.i("Portal", "From: ${route.fromAmountUSD} USD") Log.i("Portal", "To: ${route.toAmountUSD} USD") // Process route steps processRouteSteps(route.steps, request.fromChainId) } } }.onFailure { error -> Log.e("Portal", "Error getting routes: ${error.message}") } } ``` The response includes an array of routes with estimates, fees, and gas costs. Routes may be tagged as `RECOMMENDED`, `CHEAPEST`, or `FASTEST`. ## Collecting Integrator Fees Pass `integrator` and `fee` to collect a fee on each swap or bridge. `integrator` must be the exact integrator ID that you registered in the [LI.FI Portal](https://portal.li.fi/). `fee` is a decimal, not a percentage, so `0.005` means 0.5%. Valid values are `0 <= fee < 1`. LI.FI adds its own platform fee on top of your `fee`, so the total fee in the quote is higher than the value you pass. On `getQuote`, these are top-level fields: ```kotlin theme={null} val request = LifiQuoteRequest( fromChain = "eip155:8453", toChain = "eip155:42161", fromToken = "ETH", toToken = "USDC", fromAddress = address, fromAmount = "100000000000000", integrator = "YOUR_INTEGRATOR_ID", fee = 0.005 // 0.5% ) ``` On `getRoutes`, they live inside `options`: ```kotlin theme={null} val request = LifiRoutesRequest( fromChainId = "eip155:8453", fromAmount = "100000000000000", fromTokenAddress = "ETH", toChainId = "eip155:42161", toTokenAddress = "USDC", options = LifiRoutesRequestOptions( integrator = "YOUR_INTEGRATOR_ID", fee = 0.005 // 0.5% ), fromAddress = address ) ``` The quote response shows the fee breakdown in `estimate.feeCosts[]`. Your share appears as `integratorFee` in the `feeSplit` object: ```json theme={null} { "estimate": { "feeCosts": [ { "name": "LIFI Fixed Fee", "amount": "750", "percentage": "0.0075", "feeSplit": { "lifiFee": "250", "integratorFee": "500", "recipients": [ { "name": "lifi", "type": "FIXED", "fee": "250" }, { "name": "YOUR_INTEGRATOR_ID", "type": "FIXED", "fee": "500" } ] } } ] } } ``` Set up your integrator ID and fee receiver wallets in the [LI.FI Portal](https://portal.li.fi/) before you pass `fee`. LI.FI pays collected fees to those wallets, and you withdraw them from the LI.FI Portal. If you omit `integrator`, Portal sends its own default value and your fee configuration does not apply. The `referrer` parameter is an optional tracking string only. It does not control where fees are paid. New fee wallet configuration can take up to roughly 30 minutes to activate. Until then, quotes that pass `fee` fail with LI.FI error 1011 (`not configured for collecting fees on chain`). ## Getting Route Step Details Use the `getRouteStep` method to get detailed transaction information for a specific route step, including an unsigned transaction that you can then sign and submit to an RPC provider (the `transactionRequest` field). ```kotlin theme={null} suspend fun getStepTransactionDetails(step: LifiStep): LifiStep? { return try { val response = portal.trading.lifi.getRouteStep(step) response.getOrNull()?.data?.rawResponse } catch (error: Throwable) { Log.e("Portal", "Error getting step details: ${error.message}") null } } ``` The response includes a `transactionRequest` object with the unsigned transaction that you can sign and submit. ## Executing Swaps and Bridges After getting a quote or route step details, extract the transaction details from the `transactionRequest` object and sign the transaction. Extract the `from`, `to`, `value`, and `data` fields to sign and submit the transaction. ### Approving ERC-20 Tokens If your `fromToken` is an ERC-20, the Li.Fi router cannot move it on your behalf until you grant an on-chain allowance. Skip this step when the `fromToken` is the chain's native asset (its `address` is `0x0000000000000000000000000000000000000000`). Build the approval transaction with the `portal.delegations.approve(request)` method, then sign each transaction it returns with the same `eth_sendTransaction` flow used to sign the swap. Call this helper after obtaining a quote and before calling `executeTransaction`: ```kotlin theme={null} private const val NATIVE_TOKEN_ADDRESS = "0x0000000000000000000000000000000000000000" suspend fun approveErc20IfNeeded( quote: LifiQuoteResponse, fromAmount: String, fromChainId: String ) { val action = quote.data?.rawResponse?.action ?: return val estimate = quote.data?.rawResponse?.estimate ?: return val fromToken = action.fromToken ?: return if (fromToken.address == NATIVE_TOKEN_ADDRESS) { // Native asset — no approval needed. return } // Convert the raw fromAmount into the token's primary denomination // (e.g. raw "10000" with 6 decimals → "0.01"). Use a BigDecimal-aware // conversion to preserve precision for large values. val amount = formatUnits(fromAmount, fromToken.decimals) val request = ApproveDelegationRequest( chain = fromChainId, token = fromToken.address, delegateAddress = estimate.approvalAddress, amount = amount ) portal.delegations.approve(request).onSuccess { response -> response.transactions?.forEach { tx -> val txDict = mutableMapOf( "from" to tx.from, "to" to tx.to ) tx.data?.let { txDict["data"] = it } tx.value?.let { txDict["value"] = it } val sendResponse = portal.request( chainId = fromChainId, method = PortalRequestMethod.eth_sendTransaction, params = listOf(txDict), options = RequestOptions(signatureApprovalMemo = "Approve token for Li.Fi") // Optional signature approval memo to use for the request ) (sendResponse.result as? String)?.let { txHash -> waitForConfirmation(txHash, fromChainId) } } }.onFailure { error -> println("Approve delegation failed: ${error.message}") } } ``` This step only applies when the `fromToken` is an ERC-20. Native-asset swaps (ETH, MATIC, etc.) skip it. For more on the delegations API, see the [Manage Token Delegations](./delegations) guide. ### Signing and Submitting Transactions ```kotlin theme={null} suspend fun executeTransaction(transactionRequest: JsonElement, chainId: String) { try { // Parse the JsonElement to extract transaction parameters if (!transactionRequest.isJsonObject) { Log.e("Portal", "Invalid transaction request") return } val txParamsJson = transactionRequest.asJsonObject // Extract required fields val from = txParamsJson.get("from")?.asString val to = txParamsJson.get("to")?.asString if (from == null || to == null) { Log.e("Portal", "Missing required 'from' or 'to' field") return } // Extract value (default to 0x0 if not present) var value = "0x0" txParamsJson.get("value")?.let { valueElement -> value = when { valueElement.isJsonPrimitive && valueElement.asJsonPrimitive.isString -> valueElement.asString valueElement.isJsonPrimitive && valueElement.asJsonPrimitive.isNumber -> { String.format("0x%x", valueElement.asLong) } else -> "0x0" } } // Extract data val data = txParamsJson.get("data")?.asString ?: "" // Create transaction val ethTransaction = EthTransactionParam( from = from, to = to, value = value, data = data, gas = null, // Let Portal handle gas estimation gasPrice = null, maxFeePerGas = null, maxPriorityFeePerGas = null ) // Sign and send val sendResponse = portal.request( chainId = chainId, method = PortalRequestMethod.eth_sendTransaction, params = listOf(ethTransaction), options = RequestOptions(signatureApprovalMemo = "Bridge & swap via Li.Fi") // Optional signature approval memo to use for the request ) val txHash = sendResponse.result as? String if (txHash != null) { Log.i("Portal", "Transaction submitted: $txHash") // Wait for on-chain confirmation val confirmed = waitForConfirmation(txHash, chainId) if (confirmed) { Log.i("Portal", "Transaction confirmed") } } } catch (error: Throwable) { Log.e("Portal", "Error executing transaction: ${error.message}") } } ``` The `transactionRequest` from Li.Fi may include `gasPrice` and `gasLimit` fields. You can remove these if you want Portal to estimate the gas for you, or include them if you want to use Li.Fi's estimates. ### Processing Multiple Route Steps For routes with multiple steps, process them sequentially: ```kotlin theme={null} suspend fun processRouteSteps(steps: List, fromChainId: String): Boolean { for ((index, step) in steps.withIndex()) { Log.i("Portal", "Processing step ${index + 1}/${steps.size}: ${step.tool}") // 1. Get transaction details for this step val stepWithTx = getStepTransactionDetails(step) val transactionRequest = stepWithTx?.transactionRequest if (transactionRequest == null) { Log.e("Portal", "Failed to get transaction details for step ${index + 1}") return false } // 2. Sign and submit the transaction executeTransaction(transactionRequest, fromChainId) Log.i("Portal", "Step ${index + 1} completed") } return true } ``` ### Waiting for Transaction Confirmation ```kotlin theme={null} suspend fun waitForConfirmation( txHash: String, chainId: String, maxAttempts: Int = 30, delayMs: Long = 2000 ): Boolean { repeat(maxAttempts) { kotlinx.coroutines.delay(delayMs) try { val receiptResponse = portal.request( chainId = chainId, method = PortalRequestMethod.eth_getTransactionReceipt, params = listOf(txHash) ) val receipt = receiptResponse.result as? Map<*, *> val status = receipt?.get("status") as? String when (status) { "0x1" -> return true // Transaction succeeded "0x0" -> return false // Transaction reverted } } catch (error: Throwable) { continue } } return false // Timeout } ``` ## Tracking Transaction Status Use the `getStatus` method to track the status of your cross-chain transfer. ```kotlin theme={null} suspend fun trackLiFiStatus(txHash: String, fromChain: String) { val request = LifiStatusRequest( txHash = txHash, fromChain = fromChain ) val response = portal.trading.lifi.getStatus(request) response.onSuccess { statusResponse -> val rawResponse = statusResponse.data?.rawResponse if (rawResponse != null) { Log.i("Portal", "Status: ${rawResponse.status}") rawResponse.transactionId?.let { txId -> Log.i("Portal", "Transaction ID: $txId") } rawResponse.lifiExplorerLink?.let { explorerLink -> Log.i("Portal", "Explorer: $explorerLink") } // Check if complete when (rawResponse.status) { LifiTransferStatus.DONE -> { Log.i("Portal", "Transfer completed successfully!") } LifiTransferStatus.FAILED -> { Log.e("Portal", "Transfer failed") } else -> { Log.i("Portal", "Transfer in progress...") } } } }.onFailure { error -> Log.e("Portal", "Error getting status: ${error.message}") } } ``` ### Polling for Cross-Chain Completion For cross-chain transfers, poll the status endpoint until the transfer completes: ```kotlin theme={null} suspend fun pollForCompletion( txHash: String, fromChain: String, maxAttempts: Int = 300, pollIntervalMs: Long = 2000 ): Boolean { repeat(maxAttempts) { attempt -> try { val request = LifiStatusRequest( txHash = txHash, fromChain = fromChain ) val response = portal.trading.lifi.getStatus(request) response.onSuccess { statusResponse -> val rawResponse = statusResponse.data?.rawResponse if (rawResponse != null) { Log.i("Portal", "Polling (${attempt + 1}/$maxAttempts): ${rawResponse.status}") when (rawResponse.status) { LifiTransferStatus.DONE -> return true LifiTransferStatus.FAILED -> return false else -> {} // Continue polling } } } } catch (error: Throwable) { // Continue polling on error } kotlinx.coroutines.delay(pollIntervalMs) } return false // Timeout } ``` ## Example Flow Here's a complete example of executing a cross-chain bridge: ```kotlin theme={null} lifecycleScope.launch { try { // 1. Get user address val userAddress = portal.getAddress("eip155:1") ?: return@launch // 2. Get a quote val quoteRequest = LifiQuoteRequest( fromChain = "eip155:8453", // Base Mainnet toChain = "eip155:42161", // Arbitrum fromToken = "ETH", toToken = "USDC", fromAddress = userAddress, fromAmount = "100000000000000" // 0.0001 ETH (in wei) ) val quoteResponse = portal.trading.lifi.getQuote(quoteRequest) var txHash: String? = null quoteResponse.onSuccess { response -> val quote = response.data?.rawResponse val transactionRequest = quote?.transactionRequest if (transactionRequest == null) { Log.e("Portal", "No quote available") return@onSuccess } // 3. Approve the fromToken if it's an ERC-20 (no-op for native assets) approveErc20IfNeeded( quote = response, fromAmount = quoteRequest.fromAmount, fromChainId = quoteRequest.fromChain ) // 4. Extract transaction parameters if (!transactionRequest.isJsonObject) { Log.e("Portal", "Invalid transaction parameters") return@onSuccess } val txParamsJson = transactionRequest.asJsonObject val from = txParamsJson.get("from")?.asString val to = txParamsJson.get("to")?.asString if (from == null || to == null) { Log.e("Portal", "Missing required fields") return@onSuccess } var value = "0x0" txParamsJson.get("value")?.let { valueElement -> value = when { valueElement.isJsonPrimitive && valueElement.asJsonPrimitive.isString -> valueElement.asString valueElement.isJsonPrimitive && valueElement.asJsonPrimitive.isNumber -> { String.format("0x%x", valueElement.asLong) } else -> "0x0" } } val data = txParamsJson.get("data")?.asString ?: "" // 5. Sign and submit the transaction val ethTransaction = EthTransactionParam( from = from, to = to, value = value, data = data, gas = null, gasPrice = null, maxFeePerGas = null, maxPriorityFeePerGas = null ) val sendResponse = portal.request( chainId = quoteRequest.fromChain, method = PortalRequestMethod.eth_sendTransaction, params = listOf(ethTransaction), options = RequestOptions(signatureApprovalMemo = "Bridge & swap via Li.Fi") // Optional signature approval memo to use for the request ) txHash = sendResponse.result as? String if (txHash == null) { Log.e("Portal", "Failed to submit transaction") return@onSuccess } Log.i("Portal", "Transaction submitted: $txHash") }.onFailure { error -> Log.e("Portal", "Error: ${error.message}") return@launch } // 6. Track status for cross-chain completion if (txHash != null) { val completed = pollForCompletion( txHash = txHash, fromChain = quoteRequest.fromChain ) if (completed) { Log.i("Portal", "Bridge completed successfully!") } else { Log.e("Portal", "Bridge failed or timed out") } } } catch (error: Throwable) { Log.e("Portal", "Error: ${error.message}") } } ``` ## Best Practices 1. **Compare quotes/routes** before signing and submitting the transaction(s) to find the best option for your use case 2. **Process steps sequentially** for multi-step routes, ensuring each step completes before starting the next 3. **Handle network errors gracefully** and provide user feedback 4. **Monitor transaction status** for cross-chain transfers, as they may take longer than single-chain transactions 5. **Validate user balances** before initiating swaps or bridges ## Supported Networks Portal's Li.Fi integration supports the following mainnet networks: * Monad (`eip155:143`) * Ethereum (`eip155:1`) * Optimism (`eip155:10`) * BSC (`eip155:56`) * Gnosis (`eip155:100`) * Unichain (`eip155:130`) * Polygon (`eip155:137`) * Sonic (`eip155:146`) * Mantle (`eip155:5000`) * Base (`eip155:8453`) * Arbitrum (`eip155:42161`) * Celo (`eip155:42220`) * Avalanche (`eip155:43114`) * Linea (`eip155:59144`) * Berachain (`eip155:80094`) * Katana (`eip155:747474`) * Solana (`solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`) * Bitcoin (`bip122:000000000019d6689c085ae165831e93-p2wpkh`) For the complete list of networks Li.Fi supports across its ecosystem, refer to the [Li.Fi documentation](https://docs.li.fi). If you need a chain that isn't listed above, contact Portal support. **Testnets are not supported.** ## Next Steps * Learn about [signing transactions](./sign-a-transaction) * Explore [sending tokens](./send-tokens) * Check out [Portal API methods](./portal-api-methods) # Manage wallet lifecycle states Source: https://docs.portalhq.io/sdks/android/guide/manage-wallet-lifecycle-states This guide will walk you through how to manage your users' wallet lifecycle states. After initializing Portal for your user, it's crucial to determine their wallet's lifecycle state to ensure a healthy wallet state. We provide several helper methods to easily derive the state of your users' wallets: ```kotlin theme={null} // Checks if the user has created a wallet on any device. portal.doesWalletExistOrThrow() // Checks if the user's wallet share is on their current device. portal.isWalletOnDeviceOrThrow() // Checks if the user has backed up their wallet. portal.isWalletBackedUpOrThrow() // Checks if the user can recover their wallet. portal.isWalletRecoverableOrThrow() // Returns a list of available recovery methods based on the user's backups. portal.availableRecoveryMethods() ``` The previous methods `doesWalletExist()`, `isWalletOnDevice()`, `isWalletBackedUp()`, and `isWalletRecoverable()` are deprecated. These methods silently return `false` when an error occurs, which can mask underlying issues. Use the `OrThrow` variants above instead — they propagate errors so you can handle them explicitly. As of `9.1.0`, `isWalletOnDeviceOrThrow()` returns `false` when the user has no usable wallet share on the current device, rather than throwing. Having no wallet on this device is a normal lifecycle state, not an error, so it no longer surfaces as one. If you wrapped the call in a `try/catch` to treat the throw as "no wallet on device", that `catch` block no longer runs for this case. Branch on the returned `Boolean` instead: ```kotlin theme={null} // Before 9.1.0 val walletExistsOnDevice = try { portal.isWalletOnDeviceOrThrow() } catch (e: Exception) { false } // 9.1.0 and later val walletExistsOnDevice = portal.isWalletOnDeviceOrThrow() ``` Keep your `try/catch` for genuine failures — the method still throws when the client isn't set, when the `chainId` is malformed, or when a share left over from an older SDK version can't be parsed. Just don't rely on it to signal "no wallet on device". See [absent shares versus unreadable shares](./error-handling#absent-shares-versus-unreadable-shares) for the underlying change. These methods can be used together to understand the user's wallet state and determine the next steps. For example: ```kotlin theme={null} suspend fun manageWalletState(portal: Portal) { try { // Determine the wallet's state. val walletExists = portal.doesWalletExistOrThrow() val walletExistsOnDevice = portal.isWalletOnDeviceOrThrow() val walletIsRecoverable = portal.isWalletRecoverableOrThrow() if (!walletExists) { // Create and back up the wallet using portal.createWallet() and portal.backup(). } else if (!walletExistsOnDevice) { if (walletIsRecoverable) { // Recover the wallet using portal.recover(). } else { // Inform the user to back up the wallet on the original device. } } else { // The wallet is ready to use. } } catch(e: Exception) { // Handle any errors that occur. } } ``` Each method also accepts an optional `chainId` parameter (e.g., `"eip155:1"`) or a `PortalNamespace` to check the state for a specific chain instead of globally. And that's it! You're now helping your users maintain a safe and secure experience with their wallets while using your apps. # MPC progress callbacks Source: https://docs.portalhq.io/sdks/android/guide/mpc-progress-callbacks This section describes how to check in on the status of each MPC operation ## MPC Statuses These are all the possible statuses that will be returned through the callbacks on the MPC operations. ```kotlin theme={null} DecryptingShare("Decrypting share") EncryptingShare("Encrypting share") GeneratingShare("Generating share") ParsingShare("Parsing share") ReadingShare("Reading share") RecoveringBackupShare("Recovering backup share") RecoveringSigningShare("Recovering signing share") StoringShare("Storing share") Done("Done") ``` ### Generate Status Flow ```kotlin theme={null} GeneratingShare("Generating share") ParsingShare("Parsing share") StoringShare("Storing share") Done("Done") ``` ### Backup Status Flow ```kotlin theme={null} ReadingShare("Reading share") GeneratingShare("Generating share") ParsingShare("Parsing share") EncryptingShare("Encrypting share") StoringShare("Storing share") Done("Done") ``` ### Recover Status Flow ```kotlin theme={null} ReadingShare("Reading share") DecryptingShare("Decrypting share") RecoveringSigningShare("Recovering signing share") GeneratingShare("Generating share") ParsingShare("Parsing share") StoringShare("Storing share") RecoveringBackupShare("Recovering backup share") GeneratingShare("Generating share") ParsingShare("Parsing share") EncryptingShare("Encrypting share") StoringShare("Storing share") Done("Done") ``` # Noah virtual accounts and payouts Source: https://docs.portalhq.io/sdks/android/guide/noah Use portal.ramps.noah in the Android SDK for Noah KYC, payins, payouts, and quotes through the Portal Client API. The Android SDK exposes [Noah](/integrations/On-Off-Ramp/noah) virtual accounts and global payouts through `portal.ramps.noah`. Each method issues HTTP requests through `portal.api` to Portal's Noah integration on the [Client API](/apis/quickstart) using your client API key. You do not call Noah's servers directly from the app. For dashboard setup, signing keys, and supported CAIP-2 networks, see [Noah integration overview](/integrations/On-Off-Ramp/noah). For HTTP shapes and webhooks, see the [Noah workflow guides](/integrations/On-Off-Ramp/noah#workflow-guides) and [Noah Business API / EMM documentation](https://docs.noah.com/). ## Prerequisites * An [initialized `Portal` client](./getting-started) with a wallet and API access. * Noah enabled for your Portal environment and [configured in the dashboard](/integrations/On-Off-Ramp/noah). * For payins and payouts, the end user must complete [Noah KYC](/integrations/On-Off-Ramp/noah-kyc) with approved status before those flows succeed. ## Architecture | Layer | Role | | ----------------- | ----------------------------------------------------------------------------------------------------------------- | | Your app | Calls `portal.ramps.noah.*` | | Android SDK | The `Noah` class at `portal.ramps.noah` issues authenticated HTTP requests through `portal.api` to the Portal API | | Portal API | `POST/GET …/api/v3/clients/me/integrations/noah/...` with the Portal client API key | | Noah (via Portal) | Hosted KYC, banking rails, settlement | Prefer `portal.ramps.noah` over lower-level APIs. All request and response types live in `io.portalhq.android.api.data.noah`. ## Supported networks Every `network` parameter takes a CAIP-2 chain identifier. Use the constants on `NoahNetwork` rather than string literals so a typo is a compile error instead of a runtime failure. | Constant | CAIP-2 value | Noah network | | ------------------------------ | ----------------------------------------- | --------------------- | | `NoahNetwork.ETHEREUM` | `eip155:1` | `Ethereum` | | `NoahNetwork.ETHEREUM_SEPOLIA` | `eip155:11155111` | `EthereumTestSepolia` | | `NoahNetwork.BASE` | `eip155:8453` | `Base` | | `NoahNetwork.BASE_SEPOLIA` | `eip155:84532` | `BaseTestSepolia` | | `NoahNetwork.POLYGON` | `eip155:137` | `PolygonPos` | | `NoahNetwork.POLYGON_AMOY` | `eip155:80002` | `PolygonTestAmoy` | | `NoahNetwork.GNOSIS` | `eip155:100` | `Gnosis` | | `NoahNetwork.GNOSIS_CHIADO` | `eip155:10200` | `GnosisTestChiado` | | `NoahNetwork.SOLANA` | `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` | `Solana` | | `NoahNetwork.SOLANA_DEVNET` | `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` | `SolanaDevnet` | Any other value is rejected with a **400 Bad Request** (`PortalException.Api.HttpBadRequest`), because the chain is not mapped to a Noah network. A non-CAIP-2 string such as `"Ethereum"` is rejected earlier still, by the `[namespace]:[reference]` format check. Read the specific reason from the exception's `rawBody` rather than matching on message text — the wording is not a stable API. Testnet networks pair with sandbox-only test assets such as `USDC_TEST`. ## Types and responses Every response is an envelope — `data class NoahXxxResponse(val data: T, val metadata: NoahResponseMetadata? = null)`, where `NoahResponseMetadata` is a type alias for `Map`. All nine methods are `suspend` and return **`Result`**. They do not throw, and they do not return a bare value. Handle both outcomes with `onSuccess` / `onFailure`: ```kotlin theme={null} portal.ramps.noah.getPayoutCountries() .onSuccess { response -> Log.d("Noah", "${response.data.countries}") } .onFailure { error -> Log.e("Noah", "Failed", error) } ``` Every example below uses `onSuccess` / `onFailure`, `getOrNull()`, or `getOrThrow()`. Do not wrap these calls in `try/catch` — the failure is inside the `Result`, not on the stack. See [Error handling](#error-handling). ## initiateKyc Starts hosted Noah onboarding. Open `data.hostedUrl` in the system browser. Validate **HTTPS** and the **hostname** against the checkout domains Noah documents for your environment (extend the example allowlist accordingly). ```kotlin theme={null} import android.content.Intent import android.net.Uri import android.util.Log import io.portalhq.android.api.data.noah.NoahCustomerType import io.portalhq.android.api.data.noah.NoahFiatOption import io.portalhq.android.api.data.noah.NoahInitiateKycRequest val allowedHosts = setOf( "checkout.noah.com", "checkout.sandbox.noah.com", "staging-checkout.noah.com", ) portal.ramps.noah.initiateKyc( NoahInitiateKycRequest( returnUrl = "https://yourapp.example/noah/return", fiatOptions = listOf(NoahFiatOption(fiatCurrencyCode = "USD")), customerType = NoahCustomerType.INDIVIDUAL, ) ).onSuccess { response -> val uri = Uri.parse(response.data.hostedUrl) if (uri.scheme != "https" || uri.host !in allowedHosts) { Log.e("Noah", "Invalid KYC URL host or scheme") return@onSuccess } startActivity(Intent(Intent.ACTION_VIEW, uri)) }.onFailure { error -> Log.e("Noah", "initiateKyc failed", error) } ``` **Signature** ```kotlin theme={null} suspend fun initiateKyc(request: NoahInitiateKycRequest): Result ``` | Parameter | Type | Required | Description | | ---------------------- | ----------------------- | -------- | --------------------------------------------------------- | | `request.returnUrl` | `String` | Yes | HTTPS URL where Noah returns the user after onboarding. | | `request.fiatOptions` | `List?` | No | Fiat currencies to advertise during onboarding. | | `request.customerType` | `NoahCustomerType?` | No | Onboarding flow variant — `INDIVIDUAL` or `BUSINESS`. | | `request.metadata` | `Map?` | No | Client-supplied metadata forwarded to Noah. | | `request.form` | `Map?` | No | Pre-filled form payload mirrored back to the hosted flow. | The `returnUrl` must be an HTTPS URL. Custom app schemes (for example `myapp://callback`) are not supported. For mobile applications, the recommended pattern is to use an HTTPS bridge page that redirects to a deep link after KYC completion. **Returns** — `NoahInitiateKycResponse`: `{ data: { hostedUrl: String } }`. This endpoint is **idempotent**. If a Noah customer record already exists for the client, the previously stored `hostedUrl` is returned regardless of KYC status (`Pending`, `Submitted`, `Approved`, `Declined`). Calling it a second time does not restart onboarding and does not mint a fresh URL. This call only starts onboarding; KYC outcome and status changes arrive asynchronously via Noah **`Customer`** webhooks. See [Noah webhooks](/integrations/On-Off-Ramp/noah-webhooks). See also: [Noah KYC guide](/integrations/On-Off-Ramp/noah-kyc), [Noah hosted flows](https://docs.noah.com/). ## initiatePayin Creates a fiat-to-stablecoin payin and returns bank instructions and a `payinId`. Use a [supported network](#supported-networks) and the user's wallet address as `destinationAddress`. ```kotlin theme={null} import io.portalhq.android.api.data.noah.NoahInitiatePayinRequest import io.portalhq.android.api.data.noah.NoahNetwork portal.ramps.noah.initiatePayin( NoahInitiatePayinRequest( fiatCurrency = "USD", cryptoCurrency = "USDC_TEST", network = NoahNetwork.SOLANA_DEVNET, destinationAddress = "SoLAddr1111111111111111111111111111111111111", ) ).onSuccess { response -> Log.d("Noah", "Payin: ${response.data.payinId}") // Show bankDetails to the user as deposit instructions — do not log it. showDepositInstructions(response.data.bankDetails) }.onFailure { error -> Log.e("Noah", "initiatePayin failed", error) } ``` **Signature** ```kotlin theme={null} suspend fun initiatePayin(request: NoahInitiatePayinRequest): Result ``` | Parameter | Type | Required | Description | | ---------------------------- | ------------------------------- | -------- | ---------------------------------------------------------------------------------------------------- | | `request.fiatCurrency` | `String` | Yes | ISO-4217 fiat currency code the payin is denominated in. | | `request.cryptoCurrency` | `String` | Yes | Crypto asset symbol the payin resolves into. | | `request.network` | `String` | Yes | CAIP-2 chain id for `destinationAddress`. Use a [`NoahNetwork` constant](#supported-networks). | | `request.destinationAddress` | `String` | Yes | Address that receives the resulting crypto balance. | | `request.businessFees` | `Map?` | No | Per-payment-method business fees, keyed by payment method type. Forwarded to Noah as `BusinessFees`. | **Returns** — `NoahInitiatePayinResponse`: `{ data: { payinId: String, bankDetails: NoahBankDetails } }`. `NoahBankDetails` includes: * `paymentMethodId` — payment method identifier * `paymentMethodType` — payment rail type, for example `BankSepa` or `IdentifierPix` * `accountNumber` — bank account number * `cryptoCurrency` — crypto currency for this payin * `network` — network identifier * `fee` — fee breakdown (`NoahFeeDetails`: `fiatCurrencyCode`, `totalFeePct`, `totalFeeBase`, `totalFeeMin`) * `accountHolderName` — optional account holder name * `bankCode` — optional bank routing or sort code * `bankName` — optional bank name * `bankAddress` — optional `NoahBankAddress` with `street`, `street2`, `city`, `postCode`, `state`, `country` * `reference` — optional payment reference * `relatedPaymentMethods` — optional `List` `NoahBankDetails` carries sensitive financial data — `accountNumber`, `accountHolderName`, `bankCode`, and `bankAddress`. Render it to the user as deposit instructions and keep it in memory; do not write it to `Log`, crash reports, or analytics. Payin lifecycle updates are asynchronous; track them with Noah **`FiatDeposit`** and **`Transaction`** webhooks, not by polling this SDK response. See [Noah webhooks](/integrations/On-Off-Ramp/noah-webhooks). See also: [Payins](/integrations/On-Off-Ramp/noah-payins), [FiatDeposit webhooks](https://docs.noah.com/api-concepts/webhooks/fiat-deposits/). ## simulatePayin Simulates a fiat deposit landing against a payment method so you can exercise the payin flow end to end without moving real money. ```kotlin theme={null} import io.portalhq.android.api.data.noah.NoahSimulatePayinRequest portal.ramps.noah.simulatePayin( NoahSimulatePayinRequest( paymentMethodId = "pm-1", fiatAmount = "10", fiatCurrency = "USD", ) ).onSuccess { response -> Log.d("Noah", "Simulated deposit: ${response.data.fiatDepositId}") }.onFailure { error -> Log.e("Noah", "simulatePayin failed", error) } ``` **Signature** ```kotlin theme={null} suspend fun simulatePayin(request: NoahSimulatePayinRequest): Result ``` | Parameter | Type | Required | Description | | ------------------------- | -------- | -------- | -------------------------------------------------------- | | `request.paymentMethodId` | `String` | Yes | Payment method id returned by `initiatePayin`. | | `request.fiatAmount` | `String` | Yes | Amount to simulate as deposited, as a string. | | `request.fiatCurrency` | `String` | Yes | ISO-4217 fiat currency code matching the payin currency. | **Returns** — `NoahSimulatePayinResponse`: `{ data: { fiatDepositId: String, reference: String? } }`. This endpoint is **sandbox-only**. Calling it against a production environment fails with a **400 Bad Request**, surfacing as `PortalException.Api.HttpBadRequest` — not as a 403. Guard the call behind your own environment check rather than shipping it in a production code path. ## getPayoutCountries Lists countries available for fiat payouts, keyed by ISO-3166 country code with the fiat currency codes supported in each. ```kotlin theme={null} portal.ramps.noah.getPayoutCountries() .onSuccess { response -> response.data.countries.forEach { (country, currencies) -> Log.d("Noah", "$country: ${currencies.joinToString(", ")}") } } .onFailure { error -> Log.e("Noah", "getPayoutCountries failed", error) } ``` **Signature** ```kotlin theme={null} suspend fun getPayoutCountries(): Result ``` **Returns** — `NoahGetPayoutCountriesResponse`: `{ data: { countries: Map> } }`. ## getPayoutChannels Returns payout rails for a given crypto asset. Only `cryptoCurrency` is required; `country` and `fiatCurrency` narrow the results, and `fiatAmount` seeds channel-level fee calculations. ```kotlin theme={null} import io.portalhq.android.api.data.noah.NoahGetPayoutChannelsRequest portal.ramps.noah.getPayoutChannels( NoahGetPayoutChannelsRequest( cryptoCurrency = "USDC_TEST", country = "US", fiatCurrency = "USD", fiatAmount = "10", pageSize = 10, ) ).onSuccess { response -> response.data.items.forEach { channel -> Log.d("Noah", "${channel.id} ${channel.paymentMethodType} ${channel.rate}") } response.data.pageToken?.let { Log.d("Noah", "Next page: $it") } }.onFailure { error -> Log.e("Noah", "getPayoutChannels failed", error) } ``` **Signature** ```kotlin theme={null} suspend fun getPayoutChannels(request: NoahGetPayoutChannelsRequest): Result ``` | Parameter | Type | Required | Description | | ------------------------- | --------- | -------- | -------------------------------------------------------------- | | `request.cryptoCurrency` | `String` | Yes | Crypto asset symbol the payout originates from. | | `request.country` | `String?` | No | ISO-3166 country code, for example `US`. | | `request.fiatCurrency` | `String?` | No | ISO-4217 fiat currency the payout is denominated in. | | `request.fiatAmount` | `String?` | No | Amount used to seed channel-level fee calculations. | | `request.paymentMethodId` | `String?` | No | Saved payment method id to filter channels by. | | `request.pageSize` | `Int?` | No | Page size between 1 and 100, validated server-side. | | `request.pageToken` | `String?` | No | Pagination cursor from a previous response's `data.pageToken`. | **Returns** — `NoahGetPayoutChannelsResponse`: `{ data: { items: List, pageToken: String? } }`. Each `NoahChannel` includes: * `id` — channel identifier * `paymentMethodCategory` — broad grouping such as `Bank`, `Card`, or `Identifier` * `paymentMethodType` — payment rail type, for example `BankSepa` or `IdentifierPix` * `fiatCurrency` — fiat currency code * `country` — ISO country code * `limits` — `NoahChannelLimits` with `minLimit` and optional `maxLimit` * `rate` — exchange rate as a string * `processingSeconds` — estimated settlement time * `calculated` — optional `NoahChannelCalculated` with `totalFee` * `paymentMethods` — optional `List`, only populated when a customer id was supplied * `processingTier` — optional settlement speed tier such as `Standard` or `Priority` * `formSchema` — optional inline JSON Schema for the channel's payout form * `formMetadata` — optional `NoahFormMetadata` with `contentHash` * `issuer` — optional issuer identifier When a channel already carries a `formSchema`, use it directly instead of calling `getPayoutChannelForm` — it saves a round-trip. See also: [Payouts](/integrations/On-Off-Ramp/noah-payouts). ## getPayoutChannelForm Loads the dynamic form schema for a channel so you can collect recipient fields before requesting a quote. The `channelId` is URL-encoded as a single path segment, with spaces normalized to `%20`. ```kotlin theme={null} portal.ramps.noah.getPayoutChannelForm("ch-1") .onSuccess { response -> // Render form fields from response.data.formSchema per Noah's schema Log.d("Noah", "Schema keys: ${response.data.formSchema?.keys}") Log.d("Noah", "Content hash: ${response.data.formMetadata?.contentHash}") } .onFailure { error -> Log.e("Noah", "getPayoutChannelForm failed", error) } ``` **Signature** ```kotlin theme={null} suspend fun getPayoutChannelForm(channelId: String): Result ``` | Parameter | Type | Required | Description | | ----------- | -------- | -------- | -------------------------------------------- | | `channelId` | `String` | Yes | Channel identifier from `getPayoutChannels`. | **Returns** — `NoahGetPayoutChannelFormResponse`: `{ data: { formSchema: Map?, formMetadata: NoahFormMetadata? } }`. ## getPayoutQuote Requests fees and crypto amount estimates for a payout. Include `form` when the channel requires recipient data. ```kotlin theme={null} import io.portalhq.android.api.data.noah.NoahGetPayoutQuoteRequest portal.ramps.noah.getPayoutQuote( NoahGetPayoutQuoteRequest.withFiatAmount( channelId = "ch-1", cryptoCurrency = "USDC_TEST", fiatAmount = "10", ) ).onSuccess { response -> val quote = response.data Log.d("Noah", "Payout: ${quote.payoutId}") Log.d("Noah", "Send: ${quote.cryptoAuthorizedAmount}") Log.d("Noah", "Fee: ${quote.totalFee}") quote.breakdown?.forEach { Log.d("Noah", " ${it.type}: ${it.amount}") } }.onFailure { error -> Log.e("Noah", "getPayoutQuote failed", error) } ``` **Signature** ```kotlin theme={null} suspend fun getPayoutQuote(request: NoahGetPayoutQuoteRequest): Result ``` | Parameter | Type | Required | Description | | ------------------------- | ------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | `request.channelId` | `String` | Yes | Channel id returned by `getPayoutChannels`. | | `request.cryptoCurrency` | `String` | Yes | Crypto asset symbol funding the payout. | | `request.fiatAmount` | `String?` | One of `fiatAmount` or `cryptoAmount` | Fiat amount the user expects to receive. | | `request.cryptoAmount` | `String?` | One of `fiatAmount` or `cryptoAmount` | Crypto amount to sell. | | `request.quoted` | `Boolean?` | No | Request a signed, rate-locked quote. Required to later submit a `NoahQuotedOnchainDepositSourceTriggerInput` payout. | | `request.form` | `Map?` | No | Form payload satisfying the schema from `getPayoutChannelForm`. | | `request.fiatCurrency` | `String?` | No | ISO-4217 fiat currency override. | | `request.paymentMethodId` | `String?` | No | Saved payment method id to settle the payout to. | | `request.formSessionId` | `String?` | No | Existing form session to continue, for multi-step forms. | | `request.businessFee` | `NoahBusinessFee?` | No | Business fee override. Forwarded to Noah as `BusinessFee`. | ### Choosing the amount denomination `fiatAmount` and `cryptoAmount` are mutually exclusive. The primary constructor enforces this **at runtime** — passing both, or neither, throws `IllegalArgumentException` at construction, before any network call: ```kotlin theme={null} init { require((fiatAmount == null) != (cryptoAmount == null)) { "Provide exactly one of fiatAmount or cryptoAmount" } } ``` Prefer the `withFiatAmount` and `withCryptoAmount` factory helpers over the primary constructor. They make the invalid combination unrepresentable at the call site rather than deferring it to a runtime `require` check. Both are annotated `@JvmStatic`, so they are also callable from Java. ```kotlin theme={null} // Quote a fiat amount to receive NoahGetPayoutQuoteRequest.withFiatAmount( channelId = "ch-1", cryptoCurrency = "USDC_TEST", fiatAmount = "10", ) // Quote a crypto amount to sell NoahGetPayoutQuoteRequest.withCryptoAmount( channelId = "ch-1", cryptoCurrency = "USDC_TEST", cryptoAmount = "10.5", ) ``` **Returns** — `NoahGetPayoutQuoteResponse`: includes `payoutId`, `totalFee`, `cryptoAmountEstimate`, `cryptoAuthorizedAmount`, `formSessionId`, and optional `cryptoCurrency`, `fiatCurrency`, `fiatAmount`, `rate`, `breakdown`, `quote` (`{ signedQuote, expiry }`), and `nextStep`. Each `breakdown` entry is a `NoahTransactionBreakdownItem` with a `type` of `ChannelFee`, `BusinessFee`, or `Remaining`, plus an `amount`. Set `quoted = true` if you intend to submit the payout with a `NoahQuotedOnchainDepositSourceTriggerInput` trigger — that variant requires the `signedQuote` returned here. ## initiatePayout Executes a payout after quoting. Returns the destination address and the on-chain deposit conditions your transfer must satisfy. ```kotlin theme={null} import io.portalhq.android.api.data.noah.NoahInitiatePayoutRequest import io.portalhq.android.api.data.noah.NoahNetwork import java.time.Instant import java.time.temporal.ChronoUnit import java.util.UUID val expiry = Instant.now().plus(24, ChronoUnit.HOURS).truncatedTo(ChronoUnit.SECONDS).toString() // Use a stable nonce per payout attempt and reuse it on retries (max 36 characters) val nonce = UUID.randomUUID().toString().take(36) portal.ramps.noah.initiatePayout( NoahInitiatePayoutRequest( payoutId = quote.payoutId, sourceAddress = "SoLAddr1111111111111111111111111111111111111", expiry = expiry, nonce = nonce, network = NoahNetwork.SOLANA_DEVNET, ) ).onSuccess { response -> Log.d("Noah", "Destination: ${response.data.destinationAddress}") Log.d("Noah", "Conditions: ${response.data.conditions}") }.onFailure { error -> Log.e("Noah", "initiatePayout failed", error) } ``` `java.time` requires API 26 or [core library desugaring](https://developer.android.com/studio/write/java8-support-table). If your `minSdk` is lower and desugaring is not enabled, format the expiry with a UTC `SimpleDateFormat` instead. **Signature** ```kotlin theme={null} suspend fun initiatePayout(request: NoahInitiatePayoutRequest): Result ``` | Parameter | Type | Required | Description | | ----------------------- | ---------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `request.payoutId` | `String` | Yes | Payout id returned by `getPayoutQuote`. | | `request.sourceAddress` | `String` | Yes | Crypto address that funds the payout. | | `request.expiry` | `String` | Yes | ISO-8601 expiry timestamp for the trigger condition. | | `request.nonce` | `String` | Yes | Unique nonce that protects against replay. **Reuse it on retry** so repeated calls stay idempotent. Must be 36 characters or fewer. | | `request.network` | `String` | Yes | CAIP-2 chain id for `sourceAddress`. Use a [`NoahNetwork` constant](#supported-networks). | | `request.trigger` | `NoahOnchainDepositSourceTrigger?` | No | Explicit deposit trigger override. See [Deposit source triggers](#deposit-source-triggers). | | `request.businessFee` | `NoahBusinessFee?` | No | Business fee override. Forwarded to Noah as `BusinessFee`. | **Returns** — `NoahInitiatePayoutResponse`: `{ data: { destinationAddress: String?, conditions: List?, ruleId: String? } }`. `destinationAddress` is nullable because it is derived from `conditions[0].destinationAddress` and falls back to `null` when that shape is missing. `ruleId` is returned for permanent and quoted triggers. ### Deposit source triggers The payout must be authorized by a trigger. Either supply one explicitly, or rely on a saved payment method attached to the quote via `paymentMethodId` — when `trigger` is omitted and the saved method is a single on-chain deposit source, a default trigger is synthesized from the `sourceAddress`, `expiry`, and `nonce` you passed. `NoahOnchainDepositSourceTrigger` is a `sealed interface` with three implementations: | Implementation | Use it for | | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `NoahSingleOnchainDepositSourceTriggerInput` | One payout matching specific amount conditions on a network. | | `NoahPermanentOnchainDepositSourceTriggerInput` | A source that stays active across multiple deposits until expiry. Set `networkAgnostic` to match any supported network. | | `NoahQuotedOnchainDepositSourceTriggerInput` | A rate-locked payout. Requires the `signedQuote` from a `getPayoutQuote` call made with `quoted = true`. Its `expiry` is nullable — the signed quote carries its own. | Each implementation defaults its `type` property to the correct discriminator, so you do not need to set it: ```kotlin theme={null} import io.portalhq.android.api.data.noah.NoahComparisonOperator import io.portalhq.android.api.data.noah.NoahSingleOnchainDepositSourceTriggerAmountCondition import io.portalhq.android.api.data.noah.NoahSingleOnchainDepositSourceTriggerCondition import io.portalhq.android.api.data.noah.NoahSingleOnchainDepositSourceTriggerInput val trigger = NoahSingleOnchainDepositSourceTriggerInput( conditions = listOf( NoahSingleOnchainDepositSourceTriggerCondition( amountConditions = listOf( NoahSingleOnchainDepositSourceTriggerAmountCondition( comparisonOperator = NoahComparisonOperator.EQ, value = quote.cryptoAuthorizedAmount, ) ), network = NoahNetwork.SOLANA_DEVNET, ) ), sourceAddress = "SoLAddr1111111111111111111111111111111111111", expiry = expiry, nonce = nonce, ) ``` Trigger inputs serialize their fields in PascalCase (`Type`, `Conditions`, `SourceAddress`, `Expiry`, `Nonce`) to match the Noah API wire format. The same applies to `NoahBusinessFee`, whose fields serialize as `FeeBase`, `FeePct`, and `FiatCurrency` — visible in any logged request body. After this call returns `destinationAddress` and `conditions`, submit the on-chain transfer to satisfy them. You can do this with any wallet — including Portal's own [send method](./send-tokens) (`portal.sendAsset(...)`) on the same `Portal` instance, which builds, signs, and broadcasts in one call. This call initiates the payout flow; completion and failures are reported asynchronously via Noah **`Transaction`** webhooks. See [Noah webhooks](/integrations/On-Off-Ramp/noah-webhooks). See also: [Transaction events](https://docs.noah.com/api-concepts/webhooks/transactions/), [automated payout recipes](https://docs.noah.com/recipes/payout/automated-payouts). ## getPaymentMethods Returns the saved payment methods available to the customer for payouts, including a pagination token when more results exist. ```kotlin theme={null} import io.portalhq.android.api.data.noah.NoahGetPaymentMethodsRequest import io.portalhq.android.api.data.noah.NoahPaymentMethodCapability portal.ramps.noah.getPaymentMethods( NoahGetPaymentMethodsRequest( pageSize = 10, capability = NoahPaymentMethodCapability.PAYOUT_TO, ) ).onSuccess { response -> response.data.paymentMethods.forEach { method -> Log.d("Noah", "${method.id} ${method.paymentMethodCategory} ${method.displayDetails.last4}") } }.onFailure { error -> Log.e("Noah", "getPaymentMethods failed", error) } ``` The `request` parameter is defaulted, so you can call it with no arguments to use server-side defaults: ```kotlin theme={null} portal.ramps.noah.getPaymentMethods() .onSuccess { response -> Log.d("Noah", "${response.data.paymentMethods.size}") } ``` **Signature** ```kotlin theme={null} suspend fun getPaymentMethods( request: NoahGetPaymentMethodsRequest = NoahGetPaymentMethodsRequest(), ): Result ``` | Parameter | Type | Required | Description | | -------------------- | ------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `request.pageSize` | `Int?` | No | Maximum number of payment methods to return. The range 1–100 is validated server-side; out-of-range values are rejected with a 400, not clamped. | | `request.pageToken` | `String?` | No | Cursor from a previous response's `data.pageToken`. | | `request.capability` | `NoahPaymentMethodCapability?` | No | Filter by capability — `PAYOUT_FROM`, `PAYIN_TO`, or `PAYOUT_TO`. | **Returns** — `NoahGetPaymentMethodsResponse`: `{ data: { paymentMethods: List, pageToken: String? } }`. Each `NoahPaymentMethod` includes `id`, `paymentMethodCategory`, `country`, and `displayDetails`, plus optional `customerId`, `capabilities`, `accountHolderDetails`, and `issuerDetails`. ## Error handling Every Noah method returns `Result`. Failures are captured inside the `Result` rather than thrown, so handle them with `onFailure`, `fold`, or `getOrElse` — not `try/catch`. The following exceptions may appear in a failed `Result`: | Exception | Description | | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PortalException.Api.HttpBadRequest` | HTTP 400. The request was invalid — an unsupported `network`, a malformed amount, an out-of-range `pageSize`, missing required form fields, or `simulatePayin` called against a production environment. Carries `rawBody` with the raw 400 response for parsing structured backend error envelopes. | | `PortalException.Api.HttpUnauthorized` | HTTP 401. Verify your Portal client API key and that Noah is enabled for your environment. | | `PortalException.Api.HttpRequestFailed` | Any other non-success status — 4xx from 402 upward, and all 5xx. Carries a nullable `statusCode` you can branch on, for example `502` or `503` when Noah is unreachable upstream. | | `java.io.IOException` | Network or transport failure. No HTTP response was received. | `HttpBadRequest` is a **sibling** of `HttpRequestFailed` under `PortalException.Api`, not a subclass. A branch that only matches `HttpRequestFailed` will **not** catch a 400. Match both explicitly, or match the `PortalException.Api` umbrella. `HttpRequestFailed.statusCode` is `null` when no valid HTTP response was received, or for synthesized failures such as a `200` body that carried an error envelope. A `null` `statusCode` therefore means "no status to branch on", not "unknown error". ```kotlin theme={null} import io.portalhq.android.exceptions.PortalException portal.ramps.noah.getPayoutCountries() .onSuccess { response -> Log.d("Noah", "${response.data.countries}") } .onFailure { error -> when (error) { is PortalException.Api.HttpUnauthorized -> Log.e("Noah", "Check your Portal client API key and Noah configuration") is PortalException.Api.HttpBadRequest -> Log.e("Noah", "Invalid request", error) is PortalException.Api.HttpRequestFailed -> Log.e("Noah", "HTTP ${error.statusCode}", error) else -> Log.e("Noah", "Transport failure", error) } } ``` Log or surface errors without printing full API responses in production if they might contain sensitive identifiers. Retry only idempotent reads unless your product team confirms otherwise. ## Related documentation * [Android SDK API reference](https://portal-hq.github.io) — generated KDoc for `io.portalhq.android.ramps.noah` * [Noah integration overview](/integrations/On-Off-Ramp/noah) * [KYC](/integrations/On-Off-Ramp/noah-kyc), [Payins](/integrations/On-Off-Ramp/noah-payins), [Payouts](/integrations/On-Off-Ramp/noah-payouts), [Webhooks](/integrations/On-Off-Ramp/noah-webhooks) * [Noah docs — API concepts](https://docs.noah.com/api-concepts/transactions/) * [Noah docs — authentication & signing](https://docs.noah.com/api-concepts/authentication/signing/) # Perform swaps Source: https://docs.portalhq.io/sdks/android/guide/perform-swaps This guide will walk you through how to perform swaps with Portal's Android SDK. Please contact sales for additional pricing information on using the Swaps integrations at [partner@portalhq.io](mailto:partner@portalhq.io) The `PortalSwaps` class allows you to easily extend your existing `Portal` instance with swaps functionality. ## Initializing Swaps In order to initialize the Swaps class you need an existing `Portal` instance and a Swaps API Key. If you have not received a Swaps API Key, please contact support at [partner@portalhq.io](mailto:partner@portalhq.io). ```kotlin theme={null} val swaps = PortalSwaps("SWAPS-API-KEY", portal) ``` ## Fetching a list of Sources The `getSources()` function returns a `Map` containing valid, swappable token sources that can be used with your Portal MPC Wallet. The existing `Portal` instance that was provided at initialization is used to scope the request to the current chain. In order to retrieve this list, you can use the following code. ```kotlin theme={null} val sources = swaps.getSources(chainId = "your chain Id") ``` ### The returned list The Map returned by the `getSources()` function is a set of key/value pairs where the key is the name of the token and the value is the token address for that token. ## Fetching a swap Quote The `getQuote()` function returns a `Quote` object for a given set of arguments. ### QuoteArgs The minimum requirements for a valid set of `Quote` arguments are a `buyToken`, a `sellToken`, and either a `buyAmount` or a `sellAmount`. An example of a valid `QuoteArgs` object would look something like this. ```kotlin theme={null} val quoteArgs = QuoteArgs( buyToken: "UNI", sellToken: "ETH", sellAmount: .01, ) ``` ### Using your QuoteArgs Once you have a valid `QuoteArgs` object, you can call the `getQuote()` function. ```kotlin theme={null} val quote = swaps.getQuote(chainId = "your chain id", args = quoteArgs) ``` ### The Quote object The `Quote` object includes two properties: * `allowanceTarget` The address requiring allowances to complete the Swap * `cost` The total cost (gas fees) of the swap transaction * `transaction` The transaction to perform the swap ## Executing a Swap If you're selling ERC20 tokens, you'll need to utilize the `allowanceTarget` to give the associated contract permissions to access a given amount of the token you're selling. This requires a token allowance of at least what you're selling. If you're selling `ETH`, this step is not necessary. When you're ready to execute a swap, you can use the `Provider` on your existing `Portal` instance to sign the transaction and make a request to chain. ```kotlin theme={null} portal.provider.request( chainId = "your eth chain id", method = PortalRequestMethod.eth_sendTransaction, params = listOf(quote.transaction), options = RequestOptions(signatureApprovalMemo = "Execute swap") // Optional signature approval memo to use for the request ) ``` # Portal API methods Source: https://docs.portalhq.io/sdks/android/guide/portal-api-methods Portal API's convenient helper methods are designed to streamline data retrieval from the Portal REST API. ### `portal.api` The `api` property contains an instance of the `PortalApi` class, which has a number of helper methods to facilitate the retrieval of relevant application data from the Portal REST API. #### **`portal.api.getNFTs`** Fetches a list of non-fungible tokens (NFTs). The response is an array of objects where each object represents a unique NFT. ```kotlin theme={null} val nftAssets = portal.api.getNftAssets(chainId) ```
Example API response Please note that this is purely fictitious response data. ```json theme={null} [ { "nftId": "ethereum.0x123abc...def456.1234", "name": "Cosmic Kitty #1234", "description": "A rare, interstellar feline NFT from the Cosmic Kitties collection.", "imageUrl": "https://example.com/images/cosmic-kitty-1234.png", "chainId": "eip155:1", "contractAddress": "0x123abc...def456", "tokenId": "1234", "collection": { "name": "Cosmic Kitties", "description": "A collection of 10,000 unique space-faring felines.", "imageUrl": "https://example.com/images/cosmic-kitties-logo.png" }, "lastSale": { "price": 1500000000000000000, "currency": "ETH", "date": "2024-07-15T14:30:00Z" }, "rarity": { "rank": 42, "score": 0.95 }, "floorPrice": { "price": 1200000000000000000, "currency": "ETH" }, "detailedInfo": { "ownerCount": 1, "tokenCount": 1, "createdDate": "2024-01-01T00:00:00Z", "attributes": [ { "traitType": "Fur Color", "value": "Nebula Purple", "displayType": null }, { "traitType": "Eye Color", "value": "Starlight Blue", "displayType": null }, { "traitType": "Accessory", "value": "Jetpack", "displayType": null } ], "owners": [ { "ownerAddress": "0xabcdef...123456", "quantity": 1, "firstAcquiredDate": "2024-07-15T14:30:00Z", "lastAcquiredDate": "2024-07-15T14:30:00Z" } ], "extendedCollectionInfo": { "bannerImageUrl": "https://example.com/images/cosmic-kitties-banner.png", "externalUrl": "https://cosmickitties.io", "twitterUsername": "@CosmicKitties", "discordUrl": "https://discord.gg/cosmickitties", "instagramUsername": "@cosmic.kitties", "mediumUsername": "@CosmicKittiesNFT", "telegramUrl": "https://t.me/cosmickitties", "distinctOwnerCount": 8500, "distinctNftCount": 10000, "totalQuantity": 10000 }, "extendedSaleInfo": { "fromAddress": "0x98765...fedcba", "toAddress": "0xabcdef...123456", "priceUsdCents": 270000, "transaction": "0xfedcba...987654", "marketplaceId": "opensea", "marketplaceName": "OpenSea" }, "marketplaceInfo": [ { "marketplaceId": "opensea", "marketplaceName": "OpenSea", "marketplaceCollectionId": "cosmic-kitties", "nftUrl": "https://opensea.io/assets/ethereum/0x123abc...def456/1234", "collectionUrl": "https://opensea.io/collection/cosmic-kitties", "verified": true, "floorPrice": { "value": 1200000000000000000, "paymentToken": { "paymentTokenId": "ethereum", "name": "Ethereum", "symbol": "ETH", "address": null, "decimals": 18 }, "valueUsdCents": 270000 } } ], "mediaInfo": { "previews": { "imageSmallUrl": "https://example.com/images/cosmic-kitty-1234-small.png", "imageMediumUrl": "https://example.com/images/cosmic-kitty-1234-medium.png", "imageLargeUrl": "https://example.com/images/cosmic-kitty-1234-large.png", "imageOpengraphUrl": "https://example.com/images/cosmic-kitty-1234-og.png", "blurhash": "L9B4IwofV@of~qofM{of00WB%Mj[", "predominantColor": "#8A2BE2" }, "animationUrl": "https://example.com/animations/cosmic-kitty-1234.mp4", "backgroundColor": "#000000" } } } ] ```
#### **`portal.api.getAssets`** This endpoint retrieves the asset balances (native and token balances) for a specified blockchain. It provides detailed information on the native balance and token balances held by a given address on the specified chain. ```kotlin theme={null} val assets = portal.api.getAssets(chainId) ```
Example API response Please note that this is purely fictitious response data. ```json theme={null} { "nativeBalance": { "balance": "0.10991767582513721", "decimals": 18, "name": "Ether", "rawBalance": "109917675825137210", "symbol": "ETH", "metadata": { "logo": "https://cdn.moralis.io/eth/0x.png", "thumbnail": "https://cdn.moralis.io/eth/0x_thumb.png" } }, "tokenBalances": [ { "balance": "0.0199", "decimals": 18, "name": "Wrapped Ether", "rawBalance": "19900000000000000", "symbol": "WETH", "metadata": { "tokenAddress": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", "verifiedContract": false, "totalSupply": "48148.648311179713152621", "rawTotalSupply": "48148648311179713152621", "percentageRelativeToTotalSupply": 0.00004133033989114 } } ], "nfts": [ { "nftId": "ethereum.0x123abc...def456.1234", "name": "Cosmic Kitty #1234", "description": "A rare, interstellar feline NFT from the Cosmic Kitties collection.", "imageUrl": "https://example.com/images/cosmic-kitty-1234.png", "chainId": "eip155:1", "contractAddress": "0x123abc...def456", "tokenId": "1234", "collection": { "name": "Cosmic Kitties", "description": "A collection of 10,000 unique space-faring felines.", "imageUrl": "https://example.com/images/cosmic-kitties-logo.png" }, "lastSale": { "price": 1500000000000000000, "currency": "ETH", "date": "2024-07-15T14:30:00Z" }, "rarity": { "rank": 42, "score": 0.95 }, "floorPrice": { "price": 1200000000000000000, "currency": "ETH" }, "detailedInfo": { "ownerCount": 1, "tokenCount": 1, "createdDate": "2024-01-01T00:00:00Z", "attributes": [ { "traitType": "Fur Color", "value": "Nebula Purple", "displayType": null }, { "traitType": "Eye Color", "value": "Starlight Blue", "displayType": null }, { "traitType": "Accessory", "value": "Jetpack", "displayType": null } ], "owners": [ { "ownerAddress": "0xabcdef...123456", "quantity": 1, "firstAcquiredDate": "2024-07-15T14:30:00Z", "lastAcquiredDate": "2024-07-15T14:30:00Z" } ], "extendedCollectionInfo": { "bannerImageUrl": "https://example.com/images/cosmic-kitties-banner.png", "externalUrl": "https://cosmickitties.io", "twitterUsername": "@CosmicKitties", "discordUrl": "https://discord.gg/cosmickitties", "instagramUsername": "@cosmic.kitties", "mediumUsername": "@CosmicKittiesNFT", "telegramUrl": "https://t.me/cosmickitties", "distinctOwnerCount": 8500, "distinctNftCount": 10000, "totalQuantity": 10000 }, "extendedSaleInfo": { "fromAddress": "0x98765...fedcba", "toAddress": "0xabcdef...123456", "priceUsdCents": 270000, "transaction": "0xfedcba...987654", "marketplaceId": "opensea", "marketplaceName": "OpenSea" }, "marketplaceInfo": [ { "marketplaceId": "opensea", "marketplaceName": "OpenSea", "marketplaceCollectionId": "cosmic-kitties", "nftUrl": "https://opensea.io/assets/ethereum/0x123abc...def456/1234", "collectionUrl": "https://opensea.io/collection/cosmic-kitties", "verified": true, "floorPrice": { "value": 1200000000000000000, "paymentToken": { "paymentTokenId": "ethereum", "name": "Ethereum", "symbol": "ETH", "address": null, "decimals": 18 }, "valueUsdCents": 270000 } } ], "mediaInfo": { "previews": { "imageSmallUrl": "https://example.com/images/cosmic-kitty-1234-small.png", "imageMediumUrl": "https://example.com/images/cosmic-kitty-1234-medium.png", "imageLargeUrl": "https://example.com/images/cosmic-kitty-1234-large.png", "imageOpengraphUrl": "https://example.com/images/cosmic-kitty-1234-og.png", "blurhash": "L9B4IwofV@of~qofM{of00WB%Mj[", "predominantColor": "#8A2BE2" }, "animationUrl": "https://example.com/animations/cosmic-kitty-1234.mp4", "backgroundColor": "#000000" } } } ] } ```
#### **`portal.api.getTransactions`** Fetches a list of the client's transaction history ordered by `blockTimestamp` descending (latest transactions will come first). This includes both inbound and outbound transactions. ```kotlin theme={null} val transactions = portal.api.getTransactions(chainId = "CHAIN_ID") ``` Optional arguments can also be provided: * `limit`: (Optional) The maximum number of transactions to return. * `offset`: (Optional) The number of transactions to skip before starting to return. * `order`: (Optional) Order in which to return the transactions. Either `"asc"` or `"desc"`. * `chainId`: (Optional) ID of the chain to retrieve transactions from. Defaults to your Portal instance's chainId if not provided.
Response structure The response from the API request is a JSON array of objects. Each object represents a unique transaction and consists of the following fields: * `blockNum`: A string representing the hexadecimal number of the block in which the transaction occurred. * `uniqueId`: A unique string that uniquely identifies this transaction. It is constructed from the transaction's hash and category. * `hash`: A string that represents the hash of the transaction. This hash can be used as a unique identifier for the transaction on the blockchain. * `from`: A string that represents the address of the sender of the transaction. * `to`: A string that represents the address of the receiver of the transaction. * `value`: A floating-point number that represents the value of the transaction in the asset type mentioned in the `asset` field. * `erc721TokenId`: This field is null for non-ERC721 transactions. For ERC721 transactions, this field contains the unique identifier of the ERC721 token. * `erc1155Metadata`: This field is null for non-ERC1155 transactions. For ERC1155 transactions, this field contains any metadata associated with the ERC1155 token. * `tokenId`: This field is null for non-token transactions. For token transactions, it represents the unique identifier of the token involved in the transaction. * `asset`: A string that represents the type of asset that was transferred in the transaction. For example, the asset type could be "ETH" when transferring ETH from one address to another one. * `category`: A string that represents the category of the transaction. Can be any of the following: "external", "internal", "erc20", "erc721", "erc1155", or "specialnft". * `rawContract`: An object that contains raw contract details: * `value`: A hexadecimal string representing the raw value of the transaction. * `address`: The address of the contract. This is null for non-contract transactions. * `decimal`: A hexadecimal string representing the number of decimal places that the `value` field should be calculated to. * `metadata`: An object that contains additional details about the transaction: * `blockTimestamp`: Timestamp of the block from which the transaction event originated (ISO-formatted timestamp). * `chainId`: An integer denoting the blockchain network identifier. In Kotlin, this function returns `List`.
Example API response Please note that this is purely fictitious response data. ```json theme={null} [ { "blockNum": "0x1a2b3c", "uniqueId": "0xabc123def456ghi789jkl012mno345pqr678stu901vwx234yza567b:internal", "hash": "0xabc123def456ghi789jkl012mno345pqr678stu901vwx234yza567b", "from": "0x1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", "to": "0x0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1", "value": 0.257842, "erc721TokenId": null, "erc1155Metadata": null, "tokenId": null, "asset": "ETH", "category": "internal", "rawContract": { "value": "0x37fa23dbc90000", "address": null, "decimal": "0x12" }, "metadata": { "blockTimestamp": "2023-08-30T23:11:24.000Z" } }, { "blockNum": "0x2b3c4d", "uniqueId": "0xdef456ghi789jkl012mno345pqr678stu901vwx234yza567bca123:internal", "hash": "0xdef456ghi789jkl012mno345pqr678stu901vwx234yza567bca123", "from": "0x2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1", "to": "0x1a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1", "value": 0.367925, "erc721TokenId": null, "erc1155Metadata": null, "tokenId": null, "asset": "ETH", "category": "internal", "rawContract": { "value": "0x50ca24dbfc4000", "address": null, "decimal": "0x12" }, "metadata": { "blockTimestamp": "2023-08-30T23:11:24.000Z" } } ] ```
#### **`portal.api.getBackupShareMetadata`** Fetches a list of the client's backup shares' metadata, such as the backup method, when it was created, and the backup share's `id`. ```kotlin theme={null} val backupSharePairs = portal.api.getBackupShareMetadata() ```
Response structure The response is a JSON array of backup share pairs. Each backup share pair object provides information about the backup method, when it was created, and its `id`. Each backup share pair object contains the following fields: * `backupMethod`: A string representing the backup method that was used for this backup run. One of: `CUSTOM`, `GDRIVE`, `ICLOUD`, `PASSWORD`, `PASSKEY`, or `UNKNOWN`. * `id`: A string representing the "backup share pair ID" for the client's specific backup run. This is the same ID that can be found within the backup share JSON string we send to your `/backup` webhook endpoint. * `createdAt`: A string representing the datetime that the backup was created. Note: This endpoint only returns back backup shares' metadata that successfully completed the backup process. In Kotlin, the completion handler returns `List`.
Example API response Please note that this is purely fictitious response data. ```json theme={null} [ { "backupMethod": "UNKNOWN", // Legacy (v1-v5) "id": "cuid1", "createdAt": "2023-11-28T21:55:13.630Z" }, { "backupMethod": "PASSWORD", "id": "cuid2", "createdAt": "2023-11-28T21:55:13.630Z" }, { "backupMethod": "GDRIVE", "id": "cuid3", "createdAt": "2023-11-28T21:55:13.630Z" }, { "backupMethod": "ICLOUD", "id": "cuid4", "createdAt": "2023-11-28T21:55:13.630Z" }, { "backupMethod": "PASSKEY", "id": "cuid5", "createdAt": "2023-11-28T21:55:13.630Z" }, ] ```
# Recover a wallet Source: https://docs.portalhq.io/sdks/android/guide/recover-a-wallet This guide will walk you through how to use your users' backups to recover their wallet. ## Portal-Managed Recovery (default) When using Portal-Managed Backups, you can simply call the `portal.recoverWallet()` function to generate new signing shares on your user's device. ```kotlin theme={null} // With Password backup val backupConfigs = BackupConfigs(PasswordStorageConfig(password = PASSWORD)) portal.recoverWallet(backupMethod = BackupMethods.Password, backupConfigs = backupConfigs) { status -> // (Optional) Get status updates on the recovery operation } // With GDrive backup portal.recoverWallet(BackupMethods.Gdrive) { status -> // (Optional) Get status updates on the recovery operation } // With PasskeyBackups portal.recoverWallet(BackupMethods.Passkey) { status -> // (Optional) Get status updates on the recovery operation } // With Firebase Auth Backup (user must be signed in to Firebase) portal.recoverWallet(BackupMethods.Firebase) { status -> // (Optional) Get status updates on the recovery operation } ``` **WARNING**: To recover a wallet with the Portal SDK, your device must be configured to use passcode authentication. Please note that if you disable your passcode authentication after executing the `recover` function, you will need to run the `recover` function again. ## Self-Managed Recovery By default, Portal manages storing both the encrypted client backup share and the custodian backup share for you. If you prefer to store and manage the backup shares in your own infrastructure instead of using Portal-Managed Backups, see our [Self-Managed Backups](../../../resources/self-managed-backups) guide. Before recovering, you will need to retrieve the encrypted client backup share from your API. You will then provide the encrypted client backup share to `portal.recoverWallet`. Here's an example of how that might look in your code: ```kotlin theme={null} // Imports... class MainActivity : AppCompatActivity() { lateinit var portal: Portal lateinit var recoverButton: Button // Your API instance. private val exchangeApi: Api = Api() // The user from your API instance. lateinit var user: User override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) recoverButton = findViewById(R.id.recoverButton) recoverButton.setOnClickListener { handleRecover() } } private fun handleRecover() { lifecycleScope.launch { try { // Retrieve the existing encrypted client backup share from your API. val cipherText = exchangeApi.getCipherText(user.id, "GDRIVE") // Set a new signing share by running recover. portal.recoverWallet( cipherText = cipherText, backupMethod = BackupMethods.Gdrive ) { status -> Log.println(Log.INFO, "[PORTAL]", "Recover status: ${status.status} is done: ${status.done}") } } catch (err: Exception) { // Handle any errors recovering the wallet. Re-run recover. Log.e("[PORTAL]", "Recovery failed: ${err.message}") } } } } ``` **WARNING**: To recover a wallet with the Portal SDK, your device must be configured to use passcode authentication. Please note that if you disable your passcode authentication after executing the `recover` function, you will need to run the `recover` function again. ## Progress Callbacks You can learn how to handle the progress callbacks for `portal.recoverWallet` [here](./mpc-progress-callbacks). ## Next steps Amazing! Your users can now easily recover their wallet. Next let's dive into handling sessions across multiple devices for your users. # Request tracing Source: https://docs.portalhq.io/sdks/android/guide/request-tracing Correlate Portal SDK requests with your own logs using the X-Portal-Trace-Id header. Every request the Portal SDK makes to Portal's own APIs carries a trace ID. Portal's server-side logs are indexed by that value, so a single ID turns "a signing request failed sometime Tuesday" into an exact record. As of Android SDK `9.1.0` you can supply your own trace ID, log it next to your own request IDs, and hand it to Portal support. ## Overview A trace ID is a UUID v4 sent as the `X-Portal-Trace-Id` HTTP header. The SDK attaches it to requests it makes to Portal-owned hosts, and maps it to the `reqId` field on MPC signing metadata so the same value covers both the API call and the MPC operation it triggers. There are two ways a trace ID comes into existence: * **The SDK generates one.** This is the default and requires no code changes. * **You supply one.** Pass it through `RequestOptions`, `SendAssetParams`, or `rawSign` to stitch Portal's traces into your own observability. ## Automatic tracing You do not have to do anything. When you do not supply a trace ID, the SDK generates a fresh UUID for each request. Tracing is always on, and there is no way to disable it. ## Which requests carry the header The header is attached **only when the request targets a Portal-owned host**. The SDK checks the URL with `isPortalOwnedUrl` before setting the header, which is true for: * Hosts containing `portalhq` — for example `api.portalhq.io` and `mpc.portalhq.io` * Local development hosts — `localhost`, `*.localhost`, `127.0.0.1`, and `10.0.2.2` (the Android emulator's host loopback) Everything else is excluded. In practice that means: * **Third-party RPC endpoints** configured through your gateway config do **not** receive the header. If you inspect traffic to your own Alchemy or Infura endpoint and see no `X-Portal-Trace-Id`, that is expected behavior, not a bug. * **Google Drive** requests made while storing or fetching a backup share do **not** receive the header. Two consequences worth internalizing: you will not see the header on traffic to the third-party RPC providers you configure, and the SDK will not hand a correlation ID to them. The host check is a substring match on `portalhq`, so a custom host that happens to contain `portalhq` is treated as Portal-owned. `isPortalOwnedUrl` is public, so you can check a URL yourself: ```kotlin theme={null} import io.portalhq.android.utils.isPortalOwnedUrl val rpcUrl = "https://api.portalhq.io/rpc/v1/eip155/11155111" if (isPortalOwnedUrl(rpcUrl)) { // This request will carry X-Portal-Trace-Id Log.i("Tracing", "Portal-owned host — trace header will be attached") } else { Log.i("Tracing", "Third-party host — trace header will be omitted") } ``` ## Supplying your own trace ID Three public entry points accept a trace ID. ### Provider requests `RequestOptions` gained a `traceId` property: ```kotlin theme={null} data class RequestOptions( val signatureApprovalMemo: String? = null, val sponsorGas: Boolean? = null, val traceId: String? = null, ) ``` ```kotlin theme={null} import io.portalhq.android.provider.data.EthTransactionParam import io.portalhq.android.provider.data.PortalRequestMethod import io.portalhq.android.provider.data.RequestOptions import io.portalhq.android.storage.mobile.PortalNamespace import io.portalhq.android.utils.generateTraceId val traceId = generateTraceId() val params = listOf( EthTransactionParam( from = portal.getAddress(PortalNamespace.EIP155)!!, to = "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", gas = "0x6000", gasPrice = null, maxFeePerGas = null, maxPriorityFeePerGas = null, value = "0x1", data = "", ), ) val result = portal.request( chainId = "eip155:11155111", method = PortalRequestMethod.eth_sendTransaction, params = params, options = RequestOptions( signatureApprovalMemo = "Send 1 wei", traceId = traceId, ), ) Log.i("Signing", "Sent portalTraceId=$traceId txHash=${result.result}") ``` `RequestOptions.traceId` is **always** mapped to the MPC signing metadata `reqId`, even when the RPC URL itself is a third-party host that does not receive the header. That is the non-obvious payoff: one value correlates the API request and the MPC signing operation it triggers. ### Sending assets `SendAssetParams` gained a `traceId` property: ```kotlin theme={null} data class SendAssetParams( val to: String, val amount: String, val token: String, val signatureApprovalMemo: String? = null, val sponsorGas: Boolean? = null, val traceId: String? = null, ) ``` ### Raw signing `rawSign` gained a four-argument overload. The original three-argument signature is unchanged: ```kotlin theme={null} suspend fun rawSign( message: String, chainId: String, signatureApprovalMemo: String? = null, ): PortalProviderResult suspend fun rawSign( message: String, chainId: String, signatureApprovalMemo: String?, traceId: String?, ): PortalProviderResult ``` ```kotlin theme={null} import io.portalhq.android.utils.generateTraceId val traceId = generateTraceId() val result = portal.rawSign( message = "0x48656c6c6f", chainId = "eip155:11155111", signatureApprovalMemo = "Sign login challenge", traceId = traceId, ) Log.i("RawSign", "Signature portalTraceId=$traceId signature=${result.result}") ``` ## Multi-step flows share one ID When an operation makes several requests, all of them carry the same trace ID. `sendAsset` resolves one trace ID at the start of the call and reuses it for the build-transaction request, the MPC signing operation, and the broadcast. Pass `traceId` in `SendAssetParams` and the whole flow is correlated under your value: ```kotlin theme={null} import io.portalhq.android.data.SendAssetParams import io.portalhq.android.utils.generateTraceId val traceId = generateTraceId() Log.i("Transfer", "Starting transfer portalTraceId=$traceId") portal.sendAsset( chainId = "eip155:11155111", params = SendAssetParams( to = "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", amount = "0.01", token = "NATIVE", traceId = traceId, ), ).onSuccess { response -> Log.i("Transfer", "Submitted portalTraceId=$traceId txHash=${response.data?.txHash}") }.onFailure { error -> Log.e("Transfer", "Failed portalTraceId=$traceId", error) } ``` MPC wallet operations — `createWallet`, `backupWallet`, `recoverWallet`, and wallet ejection — and the EIP-7702 upgrade in `portal.evmAccountType.upgradeTo7702` also share a single trace ID across all of their sub-requests. These operations generate the ID internally and do not accept one from the caller. Generate one trace ID per user-facing action, not per request. A single ID spanning "user tapped Send" is what makes the correlation useful; a fresh ID per HTTP call tells you nothing you did not already know. ## Reading the ID back `BackupWalletResponse` gained `shareIds` and `traceId`: ```kotlin theme={null} data class BackupWalletResponse( val cipherText: String, val storageCallback: suspend () -> Result, val shareIds: List = emptyList(), val traceId: String = "", ) ``` This is the **only** place the SDK hands a generated trace ID back to you. No other response type exposes one, so do not go looking for the same accessor elsewhere. ```kotlin theme={null} import io.portalhq.android.mpc.data.BackupMethods try { val backupResponse = portal.backupWallet(backupMethod = BackupMethods.Password) Log.i( "Backup", "Complete portalTraceId=${backupResponse.traceId} shares=${backupResponse.shareIds}", ) } catch (error: Throwable) { Log.e("Backup", "Backup failed", error) } ``` `traceId` defaults to an empty string, not `null`. Check it with `isNotEmpty()` rather than a null check. ## Helpers The `io.portalhq.android.utils` package exposes the tracing primitives the SDK uses internally. ```kotlin theme={null} const val PORTAL_TRACE_ID_HEADER = "X-Portal-Trace-Id" fun generateTraceId(): String fun resolveTraceId(traceId: String?): String fun isPortalOwnedUrl(url: String): Boolean fun Map.traceIdHeaderValue(): String? fun Map.hasTraceIdHeader(): Boolean ``` | Helper | Purpose | | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | `PORTAL_TRACE_ID_HEADER` | The header name, `"X-Portal-Trace-Id"`. Use the constant instead of hardcoding the string. | | `generateTraceId()` | Generates a new UUID v4 trace ID in the format Portal expects. | | `resolveTraceId(traceId)` | Returns the supplied ID when it is non-blank, otherwise generates a fresh one. Use it when threading an optional ID through your own layers. | | `isPortalOwnedUrl(url)` | Whether a URL will receive the header. Returns `false` for a malformed URL. | | `Map.traceIdHeaderValue()` | Reads the trace ID out of a header map. The key lookup is case-insensitive and blank values are skipped. | | `Map.hasTraceIdHeader()` | Whether a header map already carries a non-blank trace ID. | `resolveTraceId` exists so your own functions can take an optional trace ID and always have a concrete value to log: ```kotlin theme={null} import io.portalhq.android.data.SendAssetParams import io.portalhq.android.utils.resolveTraceId suspend fun transfer(to: String, amount: String, traceId: String? = null) { val id = resolveTraceId(traceId) // The caller's ID, or a fresh one Log.i("Transfer", "portalTraceId=$id to=$to amount=$amount") portal.sendAsset( chainId = "eip155:11155111", params = SendAssetParams( to = to, amount = amount, token = "NATIVE", traceId = id, ), ).onFailure { error -> Log.e("Transfer", "Failed portalTraceId=$id", error) } } ``` The two `Map` extensions are for code that builds or inspects header maps directly — an OkHttp interceptor that stamps your own outbound requests with the same ID, for example: ```kotlin theme={null} import io.portalhq.android.utils.PORTAL_TRACE_ID_HEADER import io.portalhq.android.utils.hasTraceIdHeader import io.portalhq.android.utils.resolveTraceId import io.portalhq.android.utils.traceIdHeaderValue import okhttp3.Interceptor import okhttp3.Response class TraceIdInterceptor(private val traceId: String) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() val headers = request.headers.toMap() if (headers.hasTraceIdHeader()) { Log.i("Tracing", "Already traced: ${headers.traceIdHeaderValue()}") return chain.proceed(request) } val traced = request.newBuilder() .header(PORTAL_TRACE_ID_HEADER, resolveTraceId(traceId)) .build() return chain.proceed(traced) } } ``` ## Sharing a trace ID with Portal support Log the trace ID alongside your own request IDs so that when something goes wrong you can look up both sides of the same event. Include the ID in support tickets — it is the fastest way for Portal to find the exact request. A trace ID is not a secret, but it is a correlation handle. Do not log it next to user PII in a system with a different retention policy than your Portal logs. ## Signature changes in 9.1.0 Threading `traceId` through the transport and API layers touched a lot of signatures. Almost none of it is breaking. If you only **call** Portal's APIs from Kotlin, `9.1.0` requires no changes. Every `Api` and `EvmAccountTypeApi` method that gained a `traceId` parameter did so through an overload, or — for `ejectClient` and `storedClientBackupShare` — through a default value plus `@JvmOverloads`, so those original signatures survive in both source and bytecode. `PortalRequests` is the exception: its methods gained a defaulted `traceId` *without* `@JvmOverloads`, so Kotlin source callers are unaffected but the pre-`9.1.0` JVM descriptors are not preserved — see [Transport layer](#transport-layer) below. ### The overload-pair pattern Most `Api` methods kept their original arity and gained a new one alongside it: ```kotlin theme={null} open suspend fun getClient(): ClientResponse open suspend fun getClient(traceId: String?): ClientResponse open suspend fun getClientCipherText(backupSharePairId: String): String open suspend fun getClientCipherText(backupSharePairId: String, traceId: String?): String open suspend fun prepareEject(walletId: String, backupMethod: BackupMethods): String open suspend fun prepareEject(walletId: String, backupMethod: BackupMethods, traceId: String?): String open suspend fun refreshClient() open suspend fun refreshClient(traceId: String?) open suspend fun storeClientCipherText(backupSharePairId: String, cipherText: String): Boolean open suspend fun storeClientCipherText(backupSharePairId: String, cipherText: String, traceId: String?): Boolean open suspend fun updateSharePairStatus( type: PortalMpcShareType, status: PortalMpcShareStatus, sharePairIds: List, ): Boolean open suspend fun updateSharePairStatus( type: PortalMpcShareType, status: PortalMpcShareStatus, sharePairIds: List, traceId: String?, ): Boolean open suspend fun buildEip155Transaction( chainId: String, params: BuildTransactionParam, ): Result open suspend fun buildEip155Transaction( chainId: String, params: BuildTransactionParam, traceId: String?, ): Result open suspend fun buildSolanaTransaction( chainId: String, params: BuildTransactionParam, ): Result open suspend fun buildSolanaTransaction( chainId: String, params: BuildTransactionParam, traceId: String?, ): Result ``` `EvmAccountTypeApi` follows the same pattern for `getStatus`, `buildAuthorizationList`, and `buildAuthorizationTransaction`. `EvmAccountTypePortalDependency` — the small interface you implement if you supply your own Portal dependency to `EvmAccountType` — gained a defaulted overload, so existing implementations still compile: ```kotlin theme={null} suspend fun rawSign( message: String, chainId: String, signatureApprovalMemo: String?, traceId: String?, ): PortalProviderResult = rawSign(message, chainId, signatureApprovalMemo) ``` ### Two methods changed in place `ejectClient` and `storedClientBackupShare` gained `traceId` **in place** with a default value rather than through a hand-written overload: ```kotlin theme={null} @JvmOverloads open fun ejectClient(traceId: String? = null): Result @JvmOverloads fun storedClientBackupShare( success: Boolean, backupMethod: BackupMethods, traceId: String? = null, ): Result ``` Both carry `@JvmOverloads`, so the compiler also emits the original zero-`traceId` arity into the bytecode. The `9.0.x` JVM descriptors are preserved, which means existing calls keep resolving and no recompile is required: ```kotlin theme={null} // Both of these still compile and still resolve against 9.1.0 portal.api.ejectClient() portal.api.storedClientBackupShare(true, BackupMethods.Password) ``` ### Transport layer Every `PortalRequests` method gained a trailing defaulted `traceId`: ```kotlin theme={null} open suspend fun delete(url: URL, bearerToken: String? = null, traceId: String? = null): String open suspend fun get(url: URL, bearerToken: String? = null, traceId: String? = null): String open suspend fun patch(url: URL, bearerToken: String? = null, payload: String, traceId: String? = null): String open suspend fun post(url: URL, bearerToken: String? = null, payload: String?, traceId: String? = null): String open suspend fun put(url: URL, bearerToken: String? = null, payload: String?, traceId: String? = null): String ``` This is the one change in `9.1.0` that can break a build. These methods changed in place rather than gaining an overload, so if you subclass `PortalRequests` to stub the transport in tests, your existing `override` fails with `'get' overrides nothing.` until you add the `traceId` parameter. Because there is no `@JvmOverloads` here, the pre-`9.1.0` JVM method descriptors are gone as well. Kotlin source callers are fine — the default value covers them — but a binary compiled against `9.0.x` that calls these methods directly will not link against `9.1.0`. Recompile any such module rather than dropping the new SDK in beside it. ```kotlin theme={null} import io.portalhq.android.utils.PortalRequests import java.net.URL class FakeRequests : PortalRequests() { // Before 9.1.0: override suspend fun get(url: URL, bearerToken: String?): String override suspend fun get(url: URL, bearerToken: String?, traceId: String?): String { return """{"result":"stubbed"}""" } } ``` ### Other affected types | Type | Change | | ---------------------- | ----------------------------------------------------------------------------------------------------- | | `RequestOptions` | Gained `traceId`, plus a secondary constructor matching the pre-`9.1.0` argument list. | | `SendAssetParams` | Gained `traceId` as a trailing defaulted property. | | `BackupWalletResponse` | Gained `shareIds` and `traceId`, plus a secondary constructor matching the pre-`9.1.0` argument list. | | `MpcMetadata` | Gained `reqId`, serialized as `"reqId"`. | | `MpcSigner.sign` | Gained a trailing `reqId: String? = null`, written onto the signing metadata. | ## Next Steps * Turn on SDK logs to see requests as they happen — [Configure log level](./configure-log-level) * Learn how Portal surfaces failures — [Error handling](./error-handling) * Review the signing methods that accept a trace ID — [Sign a transaction](./sign-a-transaction) * Call Portal's APIs directly — [Portal API methods](./portal-api-methods) # Send tokens Source: https://docs.portalhq.io/sdks/android/guide/send-tokens This guide will walk you through how to send tokens from your Portal wallet to another address. ## Fund your Wallet Now that you have a wallet, the next step is to get test tokens for it. You can fund your wallet using **`portal.receiveTestnetAsset`**. If you are looking for a greater variety of test tokens, we recommend exploring our [faucets page](../../../resources/testnet-faucets). The `chainId` will need to be a [CAIP-2](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md) compliant Chain ID. For more info on Chain ID formatting, see [this doc](../../../resources/chain-id-formatting). ```kotlin theme={null} val chainId = "eip155:10143" // Monad Testnet val params = FundParams( amount = "0.01", // You will receive 0.01 MON token = "NATIVE" // Token, use "NATIVE" for the chain's native token ) // Fund your Portal wallet val response = portal.receiveTestnetAsset(chainId, params) println("✅ Transaction hash: ${response.data.txHash}") ``` ## Sending Tokens from your Wallet Portal provides two ways to send transactions: 1. **`portal.sendAsset()`** - A simple method for sending tokens from your Portal wallet. 2. **`portal.provider.request()`** - Direct access to the underlying web3 provider for custom transactions. (You can learn more about this method [here](./sign-a-transaction).) For most use cases, we recommend using **`portal.sendAsset()`** as shown in the examples below. ### Submitting an EVM Transaction ```kotlin theme={null} val chainId = "eip155:10143" // Monad Testnet val params = SendAssetParams( amount = "0.0001", // Sends 0.0001 MON to = "0xDestinationAddress", // The recipient address token = "NATIVE", // Token, use "NATIVE" for the chain's native token signatureApprovalMemo = "Send MON" // Optional signature approval memo to use for the request ) // Send the tokens val txHash = portal.sendAsset(chainId, params) println("✅ Transaction hash: ${txHash}") ``` The optional `signatureApprovalMemo` in `SendAssetParams` is shown to the user during the signing approval flow. The same memo is supported on `portal.request(...)` (via `RequestOptions(signatureApprovalMemo = ...)`) and `portal.rawSign(...)`. See [Sign a transaction](./sign-a-transaction#signature-approval-memo). **Account Abstraction clients:** `txHash` is a **UserOperation hash**, not an on-chain transaction hash — it will **not** resolve on a block explorer such as Etherscan or Monadscan. The on-chain transaction hash is only assigned once the bundler includes the UserOperation on-chain. Look up the UserOperation hash on a UserOp explorer such as [JiffyScan](https://jiffyscan.xyz/) to find the resulting transaction hash. See [Account abstraction](../../../resources/account-abstraction). ### Submitting a Solana Transaction You will need **`SOL`** to submit a Solana transaction, which is not currently supported by **`portal.receiveTestnetAsset`**. You can find a faucet to get test **`SOL`** tokens [here](../../../resources/testnet-faucets). ```kotlin theme={null} val chainId = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" // Solana Devnet val params = SendAssetParams( amount = "0.0001", // Sends 0.0001 SOL to = "0xDestinationAddress", // The recipient address token = "NATIVE", // Token, use "NATIVE" for the chain's native token signatureApprovalMemo = "Send SOL" // Optional signature approval memo to use for the request ) // Send the tokens val txHash = portal.sendAsset(chainId, params) println("✅ Transaction hash: $txHash") ``` You just sent your first token from your Portal wallet, that's awesome! 🎉 You may have a more advanced use case than simply sending tokens from your Portal wallet. Next, we will dive into how to build your own transaction and also how to sign it (without submitting it). # Sign a transaction Source: https://docs.portalhq.io/sdks/android/guide/sign-a-transaction This guide will walk you through how to sign a transaction using the Portal Android SDK. This example shows how the **Portal Provider** interacts with the **MPC wallet** and **the blockchain**. The **params** have a hardcoded address for the transaction of sending *1 wei* from our **MPC wallet**. The **Provider** then receives a *signed transaction* from our **mobile MPC library** and submits that to chain using the configured RPC url. Here is a quick example of how you can make requests using Portal's web3 provider: Ensure you have set the gateway URL correctly with [Infura](https://www.infura.io/) or [Alchemy](https://www.alchemy.com/) when you initialize the portal class. ```kotlin theme={null} // Imports... class MainActivity : AppCompatActivity() { lateinit var portal: Portal private fun sendOneWei() { lifecycleScope.launch { val params = listOf( EthTransactionParam( from = address, to = toAddress, gas = "0x6000", gasPrice = null, maxFeePerGas = null, maxPriorityFeePerGas = null, value = "0x${BigInteger("1").toString(16)}", data = "", ), ) Log.println(Log.INFO, "[PortalEx]", "Sending 1 wei: $params") try { Log.println( Log.INFO, "[PortalEx]", "Sending request for 'eth_SendTransaction' with params $params", ) val result = portal.request( chainId = ethChainId, method = PortalRequestMethod.eth_sendTransaction, params = params, options = RequestOptions(signatureApprovalMemo = "Send 1 wei") // Optional signature approval memo to use for the request ) Log.println(Log.INFO, "[PortalEx]", "Transaction hash: ${result.result}") showTestResult(TestCase.Send1Wei, true, "Transaction hash: ${result.result}") } catch (err: Throwable) { Log.println(Log.WARN, "[PortalEx]", "Failed to send transaction: $err") showTestResult(TestCase.Send1Wei, false) } } } ``` ### Signature approval memo You can pass an optional `signatureApprovalMemo` so the user sees a short description during the signing approval flow. It is supported across Portal's signing methods: * `portal.request(...)` — provide it via `RequestOptions(signatureApprovalMemo = ...)` on any signing method (`eth_sendTransaction`, `sol_signAndSendTransaction`, `eth_signUserOperation`, etc.). * `portal.sendAsset(chainId, params)` — include `signatureApprovalMemo` in `SendAssetParams`. See [Send tokens](./send-tokens). * `portal.rawSign(message, chainId, signatureApprovalMemo)` — pass it directly as the third argument. ### Signing a User Operation If your client uses [Account Abstraction](../../../resources/account-abstraction), you can sign an [ERC-4337 User Operation](https://eips.ethereum.org/EIPS/eip-4337) using the `eth_signUserOperation` method. This signs the User Operation without submitting it on-chain, returning the signature directly. ```kotlin theme={null} private fun signUserOperation() = lifecycleScope.launch(Dispatchers.IO) { val address = portal.getAddress(PortalNamespace.EIP155) if (address.isNullOrEmpty()) return@launch val userOp = mapOf( "sender" to address, "nonce" to "0x0", "callData" to "0x", "callGasLimit" to "0x5208", "verificationGasLimit" to "0x5208", "preVerificationGas" to "0x5208", "maxFeePerGas" to "0x1", "maxPriorityFeePerGas" to "0x1", ) try { val result = portal.request( chainId = ethChainId, method = PortalRequestMethod.eth_signUserOperation, params = listOf(userOp), ) Log.println(Log.INFO, "[PortalEx]", "✅ Signature: ${result.result}") } catch (err: Throwable) { Log.println(Log.WARN, "[PortalEx]", "❌ Failed to sign UserOperation: $err") } } ``` **User Operation Parameters:** | Name | Type | Description | | ---------------------- | -------- | ----------------------------------------------------------------------------- | | `sender` | `String` | The address of the smart contract account | | `nonce` | `String` | Anti-replay parameter (hex-encoded) | | `callData` | `String` | The data to pass to the `sender` during the main execution call (hex-encoded) | | `callGasLimit` | `String` | Gas limit for the main execution call (hex-encoded) | | `verificationGasLimit` | `String` | Gas limit for the verification step (hex-encoded) | | `preVerificationGas` | `String` | Gas paid for pre-verification (hex-encoded) | | `maxFeePerGas` | `String` | Maximum fee per unit of gas (hex-encoded) | | `maxPriorityFeePerGas` | `String` | Maximum priority fee per unit of gas (hex-encoded) | You can also pass a signature approval memo and control gas sponsorship: ```kotlin theme={null} val result = portal.request( chainId = ethChainId, method = PortalRequestMethod.eth_signUserOperation, params = listOf(userOp), options = RequestOptions(signatureApprovalMemo = "Approve UserOp", sponsorGas = true), ) ``` ## Estimating Gas By default, Portal will estimate and populate the `gas` property in a `transaction` object if the property is undefined. To estimate the `gas` value manually use the `eth_estimateGas` RPC call and pass in your transaction as the parameter. ```kotlin theme={null} suspend fun estimateGas() { try { // Create the transaction params. val params = listOf(TransactionParams( "", "0x9AeCB4DA6b438830b88C5F40b6Bf36EF3073B350", "0x${BigInteger("1").toString(16)}", "", portal.address )) // Attempt to send the transaction. val response = portal.provider.request( chainId = "your chain Id" method = PortalRequestMethod.eth_estimateGas, params = params ) Log.d("Portal", "Estimated gas: ${response.result}") } catch (err: Exception) { // ❌ Handle errors sending the transaction. } } ``` ### Signing Solana Transactions We offer a `portal.sendSol` function to make the process of sending sol very simple. ```kotlin theme={null} suspend fun sendSolana() { kotlin.runCatching { portal.sendSol( chainId = SOLANA_DEV_NET_CHAIN_ID, lamports = 1, to = SOLANA_TEST_ADDRESS, ) }.onSuccess { response -> Timber.i("✅ Send Solana response: $response") }.onFailure { Timber.e("❌ Send Solana error: $it") } } ``` We also offer methods to build your Solana (and Eth) transactions. ```kotlin theme={null} suspend fun buildSolanaTransaction() { val buildTransactionParam = BuildTransactionParam( to = SOLANA_TEST_ADDRESS, token = "SOL", amount = "0.001" ) portal.api.buildSolanaTransaction(SOLANA_DEV_NET_CHAIN_ID, buildTransactionParam).onSuccess { response -> if (response.error == null) { Timber.i("✅ Build Transaction response: ${response.transaction}") } else { Timber.i("❌ Build Transaction response: ${response.error}") } }.onFailure { Timber.e("❌ Build Transaction error: $it") } } ``` If you would like to construct your own more advanced transaction for solana then here is how you can do it. Add [SolanaKt library](https://github.com/metaplex-foundation/SolanaKT) to your project as it will help you build the transaction. (You can also use any other library of your choice.) ```gradle theme={null} // In your settings.gradle repositories { ... maven { url 'https://jitpack.io' } } // In your app/build.gradle dependencies { ... implementation 'com.github.metaplex-foundation:SolanaKT:{version}' } ``` Then build a Solana request and send it using `portal.request(chainId, PortalRequestMethod.sol_signAndSendTransaction, listOf(solanaRequest)` ```kotlin theme={null} // Here is how you can build the solana request using SolanaKt library suspend fun sendSolana( solanaChainId: String, fromAddress: String, toAddress: String, lamports: Long ): String { val recentBlockhashRpcResponse = portal.request( solanaChainId, PortalRequestMethod.sol_getLatestBlockhash, emptyList() ).result as PortalProviderRpcResponse val recentBlockhashResult = recentBlockhashRpcResponse.result val recentBlockhash = Gson().let { it.fromJson(it.toJson(recentBlockhashResult), SolGetLatestBlockhashResult::class.java) } val solanaRequest = prepareSolanaRequest( fromAddress, toAddress, lamports, recentBlockhash.value.blockhash ) val transactionHash = portal.request( solanaChainId, PortalRequestMethod.sol_signAndSendTransaction, listOf(solanaRequest) ).result as String return transactionHash } suspend fun prepareSolanaRequest( fromAddress: String, toAddress: String, lamports: Long, recentBlockhash: String ): PortalSolanaRequest { val fromPublicKey = PublicKey(fromAddress) val toPublicKey = PublicKey(toAddress) val transferInstruction = SystemProgram.transfer( fromPublicKey, toPublicKey, lamports ) Log.i("PortalSolana", "Transfer instruction: $transferInstruction") val transaction = Transaction() transaction.addInstruction(transferInstruction) transaction.recentBlockhash = recentBlockhash transaction.feePayer = fromPublicKey val message = transaction.compileMessage() Log.i("PortalSolana", "Compiled message: $message") val header = PortalSolanaHeader( numRequiredSignatures = message.header.numRequiredSignatures, numReadonlySignedAccounts = message.header.numReadonlySignedAccounts, numReadonlyUnsignedAccounts = message.header.numReadonlyUnsignedAccounts ) val instructions = message.instructions.map { instruction -> PortalSolanaInstruction( instruction.programIdIndex, instruction.accounts, instruction.data ) } val accountKeys = message.accountKeys.map { key -> key.toString() } return PortalSolanaRequest( message = PortalSolanaMessage( accountKeys = accountKeys, header = header, recentBlockhash = message.recentBlockhash, instructions = instructions ) ) } ``` *** ### Raw sign You can now utilize our SDK to generate raw signatures that are generated using the underlying key share without adding any chain specific formatting to the signature. This effectively unlocks your ability to use the Portal SDK with any chain that uses `SECP256K1` or `ED25519`. ```kotlin theme={null} try { val result = portal.rawSign( "74657374", chainId = "eip155:1", signatureApprovalMemo = "Sign raw message" // Optional signature approval memo to use for the request ) Log.println( Log.INFO, "[Portal]", "✅ Request: raw_sign, Result: $result", ) } catch (err: Throwable) { Log.println( Log.WARN, "[Portal]", "❌ Failed to send request raw_sign: $err", ) } ``` *** ### Enabling the Enclave Signer Executing MPC operations requires computation on the client device. Depending on the CPU of the client device this can take variable amounts of time, leading to inconsistent signing times across users. To solve this, you can leverage the **Enclave MPC API** from your SDK to execute MPC operations server-side which leads to consistent (and often faster) signing speeds. This feature leverages the Enclave MPC API by sending the user's key share to a Trusted Execution Environment (TEE) which runs the MPC code in a secure AWS Nitro Enclave with the same non-custodial guarantees as client-side MPC. By enabling the `useEnclaveMpcApi` feature flag the client key share will be transmitted from the user device, but it is **never** stored. TEEs in Nitro Enclaves work by **encrypting memory** and **verifying execution.** Encrypted memory means that all of the data being processed on the enclave can’t be accessed by anything other than the running application. Portal employees can’t even read the data on there! Verified execution means that a user can cryptographically verify that their request was handled in a secure enclave. When a user sends an API request to the enclave, Portal returns a set of signed “measurements” that can be verified by the enclave’s public key to ensure that the request was processed on an AWS Nitro Enclave. To learn more check out our blog post introducing[ the Enclave MPC API](https://www.portalhq.io/post/introducing-the-enclave-mpc-api). Here’s how to enable it: ```kotlin theme={null} val portal = Portal( apiKey = "API_KEY_OR_SESSION_TOKEN", featureFlags = FeatureFlags( useEnclaveMpcApi = true // other feature flags... ) // other configurations... ) ``` By setting `useEnclaveMpcApi` to `true`, the Portal instance will use the Enclave MPC API for signing transactions, ensuring faster computation and consistent performance across client devices. *** And now you are signing transactions with Portal! 🙌 🚀 Next, we'll explore how to simulate a transaction so that you can create smoother experiences for your users. # Earn with Yield.xyz Source: https://docs.portalhq.io/sdks/android/guide/yield-xyz Learn how to discover, enter, manage, and exit yield opportunities. Portal's Android SDK provides comprehensive yield opportunities capabilities through the `portal.yield.yieldxyz` API. This guide covers discovering yield opportunities, entering positions, managing existing positions, and exiting yield opportunities. ## Overview The yield functionality allows users to: * **Discover** available yield opportunities across different protocols and networks * **Enter** yield positions by depositing tokens into yield opportunities * **Manage** existing positions (claim rewards, voting, etc.) * **Exit** yield positions to withdraw aggregated tokens and rewards * **Track** yield balances and historical yield actions ## Prerequisites Before using yield operations, ensure you have: * A properly initialized Portal client * An active wallet with the required token(s) on the target network (see [Create a wallet](./create-a-wallet)) * Yield.xyz integration enabled in your Portal Dashboard (see [Yield.xyz Integration](/integrations/Yield/yield-xyz)) ## Discovering Yield Opportunities Use the `discover` method to find available yield opportunities. For complete API documentation, see the [Yield.xyz API reference](https://docs.yield.xyz/reference/yieldscontroller_getyields). ```kotlin theme={null} val request = YieldXyzGetYieldsRequest( offset = 0, limit = 10, network = "eip155:11155111", // Sepolia network // ... other parameters ) val result = portal.yield.yieldxyz.discover(request) if (result.isSuccess) { val response = result.getOrThrow() val rawResponse = response.data?.rawResponse val yieldOpportunities = rawResponse?.items // Process and display yield opportunities } ``` Popular, high-quality USDC yield options with no lockups or limits: * USDC Aave V3 Lending: * `base-usdc-aave-v3-lending` * USDC Fluid Vault: * `base-usdc-fusdc-0xf42f5795d9ac7e9d757db633d693cd548cfd9169-4626-vault` * USDC Spark Savings Vault: * `ethereum-usdc-spusdc-0x28b3a8fb53b741a8fd78c0fb9a6b2393d896a43d-4626-vault` ## Entering Yield Positions To enter a yield position, first discover the specific yield, then use the `enter` method. For complete API documentation, see the [Yield.xyz enter yield reference](https://docs.yield.xyz/reference/actionscontroller_enteryield). For the example below, we will use the yield opportunity with the ID `"ethereum-sepolia-link-aave-v3-lending"`. Fund your Portal client with the required `LINK` token to enter the position. ```kotlin theme={null} val userAddress = portal.getAddress(PortalNamespace.EIP155) ?: return val enterRequest = YieldXyzEnterRequest( yieldId = "ethereum-sepolia-link-aave-v3-lending", address = userAddress, arguments = EnterYieldArguments( amount = "1", // 1 LINK token ) ) val enterResult = portal.yield.yieldxyz.enter(enterRequest) if (enterResult.isSuccess) { val enterResponse = enterResult.getOrThrow() val rawResponse = enterResponse.data?.rawResponse val transactions = rawResponse?.transactions // Process transactions, this is described in the "Transaction Processing" section below processTransactions(transactions) } ``` ## Checking Yield Balances Retrieve current yield positions and balances. For complete API documentation, see the [Yield.xyz get balances reference](https://docs.yield.xyz/reference/yieldscontroller_getaggregatebalances). ```kotlin theme={null} val userAddress = portal.getAddress(PortalNamespace.EIP155) ?: return val balanceRequest = YieldXyzGetBalancesRequest( queries = listOf( YieldBalanceQuery( address = userAddress, network = "eip155:11155111" // Sepolia testnet ) ) ) val balanceResult = portal.yield.yieldxyz.getBalances(balanceRequest) if (balanceResult.isSuccess) { val response = balanceResult.getOrThrow() val rawResponse = response.data?.rawResponse val yieldPositions = rawResponse?.items // Process and display yield positions information } ``` We recommend always specifying a `yieldId` on each balance query. When `yieldId` is provided, Yield.xyz can resolve balances directly, so you don't need to call the `track` endpoint after entering or exiting positions. ```kotlin theme={null} val balanceRequest = YieldXyzGetBalancesRequest( queries = listOf( YieldBalanceQuery( address = userAddress, network = "eip155:11155111", yieldId = "ethereum-sepolia-link-aave-v3-lending" ) ) ) ``` ## Exiting Yield Positions Use the `exit` method to withdraw from yield positions. For complete API documentation, see the [Yield.xyz exit yield reference](https://docs.yield.xyz/reference/actionscontroller_exityield). ```kotlin theme={null} val userAddress = portal.getAddress(PortalNamespace.EIP155) ?: return val exitRequest = YieldXyzExitRequest( yieldId = "ethereum-sepolia-link-aave-v3-lending", address = userAddress, arguments = YieldXyzEnterArguments(amount = "0.001") ) val exitResult = portal.yield.yieldxyz.exit(exitRequest) if (exitResult.isSuccess) { val exitResponse = exitResult.getOrThrow() val rawResponse = exitResponse.data?.rawResponse val transactions = rawResponse?.transactions // Process transactions, this is described in the "Transaction Processing" section below processTransactions(transactions) } ``` ## High-Level Methods Use `deposit` and `withdraw` when you want one call for the full flow: resolve the yield, build the action, sign and send each transaction in order, wait for confirmation between steps, and report each hash back to Yield.xyz. Both are `suspend` and return `Result`. `YieldWithdrawParams` and `YieldWithdrawResult` are type aliases for the deposit types, so the two methods take an identical shape. ### Signatures ```kotlin theme={null} suspend fun deposit( params: YieldDepositParams, options: YieldSubmitOptions? = null ): Result suspend fun withdraw( params: YieldWithdrawParams, options: YieldSubmitOptions? = null ): Result ``` `options` is defaulted, so `deposit(params)` alone is valid. Both run on `Dispatchers.IO`. ### Essential parameters `YieldDepositParams`: | Parameter | Type | Required | Description | | ----------- | ------------------------- | -------- | ------------------------------------------------------------------------ | | `target` | `YieldActionTarget` | Yes | Which yield to act on. See below. | | `amount` | `String` | Yes | Amount to deposit or withdraw. Merged into the action arguments. | | `arguments` | `YieldXyzEnterArguments?` | No | Protocol-specific inputs, such as `validatorAddress` for native staking. | There is **no `address` parameter.** The wallet address is resolved from your `Portal` instance for whichever chain the yield resolves to. If no wallet exists for that chain, the call fails with `YieldXyzActionException.AddressUnavailable`. `YieldActionTarget` is a sealed class with two subtypes, so you cannot supply both forms or neither: ```kotlin theme={null} sealed class YieldActionTarget { data class ByYieldId(val yieldId: String) : YieldActionTarget() data class ByChainAndToken(val chain: String, val token: String) : YieldActionTarget() } ``` | Subtype | Behavior | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `ByYieldId(yieldId)` | Acts on that yield directly, skipping the defaults lookup. | | `ByChainAndToken(chain, token)` | Resolves the yield from your Portal yield defaults. `chain` must be a **full CAIP-2 id** (`"eip155:1"`); `token` must match the defaults exactly. | `ByChainAndToken` requires full CAIP-2. A bare `"1"` fails with `YieldXyzActionException.InvalidChainId`. This differs from Li.Fi's `tradeAsset`, which forwards whatever chain format you give it — the two APIs shipped in the same release but do not accept the same values. `YieldSubmitOptions`: | Option | Type | Default | Description | | ---------------- | ---------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------- | | `onProgress` | `((YieldSubmitProgress) -> Unit)?` | `null` | Fired per transaction with `signing`, `submitted`, `confirming`, `confirmed`. | | `pollIntervalMs` | `Long` | `4_000` | **Milliseconds** between confirmation polls. Coerced to at least `1`. | | `timeoutMs` | `Long` | `900_000` | **Milliseconds** to wait for a transaction to confirm before treating it as uncertain. Coerced to at least `pollIntervalMs`. | `onProgress` is invoked on `Dispatchers.IO`, not the main thread. Switch dispatchers before touching any UI from inside the callback. These are **milliseconds**. The iOS SDK uses `pollIntervalSeconds` and `timeoutSeconds` for the same two concepts, so a value copied across platforms will be wrong by a factor of 1000. Note the enum member casing: `YieldSubmitStep` members are lowercase (`signing`, `submitted`, `confirming`, `confirmed`), while `YieldSubmitResultStatus` members are uppercase (`SUCCESS`, `PARTIAL_SUCCESS`, `FAILED`). Both are written exactly as the SDK declares them. There is no per-call signer or confirmation override — signing always goes through the Portal MPC signer. ### Return value `YieldDepositResult`: | Field | Type | Description | | ------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `hashes` | `List` | Submitted transaction hashes, in order. Contains every hash that was submitted, regardless of final outcome. | | `yieldId` | `String` | The resolved yield id. | | `status` | `YieldSubmitResultStatus` | `SUCCESS`, `PARTIAL_SUCCESS`, or `FAILED`. See below. | | `chain` | `String?` | Set only when you targeted via `ByChainAndToken`. | | `token` | `String?` | Set only when you targeted via `ByChainAndToken`. | | `yieldOpportunityDetails` | `YieldOpportunityDetails` | Action metadata — `yieldId`, `intent`, `type`, `executionPattern`, `status`, `amount`, `amountUsd`. This is action-level detail, not the full opportunity; use `discover` for that. | ### Handling Results | Status | Meaning | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `SUCCESS` | Every transaction confirmed, or the action required no confirmations. | | `PARTIAL_SUCCESS` | Execution stopped before all transactions confirmed — typically a confirmation timeout. Earlier transactions may have landed. | | `FAILED` | A transaction was confirmed as failed on-chain. Execution stopped immediately at that point. | ```kotlin theme={null} portal.yield.yieldxyz.deposit(params).onSuccess { result -> when (result.status) { YieldSubmitResultStatus.SUCCESS -> Log.i("Portal", "All transactions confirmed: ${result.hashes}") YieldSubmitResultStatus.PARTIAL_SUCCESS -> Log.w("Portal", "Stopped before completing: ${result.hashes} — check the yield balance") YieldSubmitResultStatus.FAILED -> Log.e("Portal", "Transaction failed on-chain: ${result.hashes.lastOrNull()}") } } ``` The `when` is exhaustive over the three states with no `else`. A non-empty `hashes` list does **not** mean success. Hashes are recorded as transactions are submitted, before their outcome is known. Always branch on `status`. ### Example (deposit with progress) ```kotlin theme={null} import io.portalhq.android.yield.YieldActionTarget import io.portalhq.android.yield.YieldDepositParams import io.portalhq.android.yield.YieldSubmitOptions import io.portalhq.android.yield.YieldSubmitResultStatus lifecycleScope.launch { portal.yield.yieldxyz.deposit( params = YieldDepositParams( target = YieldActionTarget.ByYieldId("ethereum-sepolia-link-aave-v3-lending"), amount = "0.001" ), options = YieldSubmitOptions( onProgress = { progress -> // Runs on Dispatchers.IO — hop to Main before touching UI. Log.i("Portal", "${progress.step} ${progress.index + 1}/${progress.total} ${progress.hash ?: ""}") }, pollIntervalMs = 4_000, // milliseconds timeoutMs = 300_000 ) ).onSuccess { result -> Log.i("Portal", "Status: ${result.status}") Log.i("Portal", "Hashes: ${result.hashes}") }.onFailure { error -> Log.e("Portal", "Deposit failed: ${error.message}", error) } } ``` ### Example (targeting by chain and token) ```kotlin theme={null} lifecycleScope.launch { portal.yield.yieldxyz.deposit( params = YieldDepositParams( target = YieldActionTarget.ByChainAndToken( chain = "eip155:11155111", token = "ETH" ), amount = "0.0000001" ) ).onSuccess { result -> Log.i("Portal", "Resolved yield: ${result.yieldId}") Log.i("Portal", "Chain: ${result.chain} Token: ${result.token}") } } ``` `chain` and `token` on the result are populated only when you target this way. ### Example (withdraw) `withdraw` takes identical parameter shapes: ```kotlin theme={null} lifecycleScope.launch { portal.yield.yieldxyz.withdraw( params = YieldWithdrawParams( target = YieldActionTarget.ByYieldId("ethereum-sepolia-link-aave-v3-lending"), amount = "0.001" ) ).onSuccess { result -> Log.i("Portal", "Status: ${result.status}") } } ``` ### Errors Failures arrive inside the returned `Result` as a `YieldXyzActionException`: | Case | When | | -------------------------------- | ------------------------------------------------------------------------------ | | `PortalNotInitialized` | No Portal dependency set for signing. Cannot occur on `portal.yield.yieldxyz`. | | `EmptyYieldId` | An empty `yieldId` was supplied. | | `InvalidChainId(chain)` | `chain` was not a full CAIP-2 id. | | `NoYieldForChainToken(key)` | No default yield matches that chain and token pair. | | `YieldNotFound(yieldId)` | The yield id does not exist. | | `AddressUnavailable(chain)` | No wallet address for the resolved chain. Create or load a wallet first. | | `NoTransactions` | The action response contained no transactions. | | `MissingTransactionField(field)` | A transaction was missing a required field. | | `UnsupportedNetwork(network)` | The action resolved to a network the high-level flow cannot sign for. | | `InvalidSignResponse` | Signing returned an unusable response. | | `InvalidUnsignedTransaction` | `unsignedTransaction` was not valid JSON and could not be parsed. | | `NoValidators(yieldId)` | `getValidators` found none for that yield. | | `ApiError(errorMessage)` | The backend returned an error payload. Carries the message. | `PortalNotInitialized`, `EmptyYieldId`, `NoTransactions`, `InvalidSignResponse`, and `InvalidUnsignedTransaction` are `object` singletons — match them with `is`. The rest are data classes carrying detail. ```kotlin theme={null} .onFailure { error -> when (error) { is YieldXyzActionException.InvalidChainId -> Log.e("Portal", "Use full CAIP-2, got: ${error.chain}") is YieldXyzActionException.AddressUnavailable -> Log.e("Portal", "No wallet on ${error.chain}") else -> Log.e("Portal", "Deposit failed", error) } } ``` ## Get Validators Fetches the validator addresses for a native-staking yield. These are used for approval flows and for populating `arguments.validatorAddress`. ```kotlin theme={null} suspend fun getValidators(yieldId: String): Result> ``` Available on both the namespace and the provider — `portal.yield.getValidators(yieldId)` is a passthrough to `portal.yield.yieldxyz.getValidators(yieldId)`. ```kotlin theme={null} lifecycleScope.launch { portal.yield.getValidators("monad-testnet-mon-native-staking") .onSuccess { validators -> validators.forEach { validator -> Log.i("Portal", "${validator.address} ${validator.name ?: "unnamed"}") } } .onFailure { error -> Log.e("Portal", "getValidators failed", error) } } ``` Only `address` is non-nullable on `YieldXyzValidator`. Everything else — `name`, `logoURI`, `website`, `rewardRate`, `provider`, `commission`, `tvlUsd`, `votingPower`, `preferred`, `minimumStake`, `status`, and the rest — depends on the protocol and may be absent. Fails with `YieldXyzActionException.NoValidators` when the response contains no validators, and `YieldXyzActionException.ApiError` when the backend returns an error payload. ### Low-level additions Two API methods back the high-level flow and are available directly on `portal.api.yieldxyz`: ```kotlin theme={null} suspend fun getYieldDefaults(includeOpportunities: Boolean? = null): Result suspend fun getYieldValidators(yieldId: String): Result ``` `getYieldDefaults` returns `data` as a map keyed **`"{caip2}:{TOKEN}"`** — for example `"eip155:1:USDC"`. That key is exactly what `ByChainAndToken` resolves against, so this is how you discover valid chain and token pairs. Pass `includeOpportunities = true` to populate each entry's `opportunity` field. Unlike the other Yield.xyz responses, the defaults payload is **not** wrapped in `rawResponse` — read it from `data` directly. ## Managing Yield Positions If your Portal client has entered into a yield balance, they may have a yield balance that has an available `pendingActions`. You can use the `manage` method to perform actions on existing yield positions. For example, if the balance has a `pendingAction` of `WITHDRAW` or `CLAIM_REWARDS`, you can use the `manage` method to withdraw or claim rewards from the yield balance. For complete API documentation, see the [Yield.xyz manage yield reference](https://docs.yield.xyz/reference/actionscontroller_manageyield). ```kotlin theme={null} val userAddress = portal.getAddress(PortalNamespace.EIP155) ?: return val manageRequest = YieldXyzManageYieldRequest( yieldId = "ethereum-sepolia-link-aave-v3-lending", address = userAddress, action = YieldActionType.WITHDRAW, // Replace with the balance's `pendingAction` item's `type` value. passthrough = "eyJhZGRyZXNzZXMiOnsiYWRkcmVzcyI6ImNvc21vczF5ZXk..." // Replace with the balance's `pendingAction` item's `passthrough` value. ) val manageResult = portal.yield.yieldxyz.manage(manageRequest) if (manageResult.isSuccess) { val manageResponse = manageResult.getOrThrow() val rawResponse = manageResponse.data?.rawResponse val transactions = rawResponse?.transactions // Process transactions, this is described in the "Transaction Processing" section below processTransactions(transactions) } ``` ## Getting Historical Actions Retrieve the history of yield actions for an address. For complete API documentation, see the [Yield.xyz get actions reference](https://docs.yield.xyz/reference/actionscontroller_getactions). ```kotlin theme={null} val userAddress = portal.getAddress(PortalNamespace.EIP155) ?: return val request = YieldXyzGetHistoricalActionsRequest(address = userAddress) val result = portal.yield.yieldxyz.getHistoricalActions(request) if (result.isSuccess) { val response = result.getOrThrow() val rawResponse = response.data?.rawResponse val pastActions = rawResponse?.items // Process and display past yield actions } ``` ## Transaction Processing (Low-Level Enter / Exit / Manage) If you use [`deposit`](#high-level-methods) or [`withdraw`](#high-level-methods), skip this section — the SDK already sequences transactions, waits for confirmation between steps, and reports hashes to Yield.xyz. This section applies to manual flows built on `enter`, `exit`, or `manage`. When `deposit` and `withdraw` sign an EVM transaction they rebuild it from the yield action's `unsignedTransaction`, copying `to`, `from`, `value`, `data` and the fee fields (`gasLimit`/`gas`, `maxFeePerGas`, `maxPriorityFeePerGas`, `gasPrice`) but **deliberately omitting `nonce`**, so the MPC signer fetches the pending nonce at signing time. If you sign manually, drop the planning `nonce` the same way — reusing it across a multi-transaction action causes nonce collisions. Yield operations can require multiple transactions. Process them sequentially, submit each, track it, and wait for on-chain confirmation (e.g. using `eth_getTransactionReceipt`) before proceeding to the next. For complete API documentation, see the [Yield.xyz submit transaction hash reference](https://docs.yield.xyz/reference/transactionscontroller_submittransactionhash) and [get transaction details reference](https://docs.yield.xyz/reference/transactionscontroller_gettransaction). For account abstraction enabled Portal clients, use `eth_getUserOperationReceipt` instead of `eth_getTransactionReceipt` to wait for confirmation, since signing returns a *user operation hash*, not a transaction hash. If you don't specify a `yieldId` on your balance queries, you'll need to call `track` after each transaction so Yield.xyz can attribute the position. Pass the **transaction hash** (extracted from `response.result.receipt.transactionHash` for AA clients), not the user operation hash. ```kotlin theme={null} fun processTransactions(transactions: List?) { val sorted = transactions?.sortedBy { it.stepIndex } ?: emptyList() for (tx in sorted) { if (tx.unsignedTransaction != null && tx.status == YieldActionTransactionStatus.CREATED) { val ok = runBlocking { signAndSubmitAndConfirm(tx) } if (!ok) break } } } suspend fun signAndSubmitAndConfirm(transaction: YieldActionTransaction): Boolean { val unsignedTxJson = transaction.unsignedTransaction as? String ?: return false val gson = Gson() val type = object : TypeToken>() {}.type val txParams = gson.fromJson>(unsignedTxJson, type) val ethTransaction = EthTransactionParam( from = txParams["from"] as? String ?: "", to = txParams["to"] as? String ?: "", value = txParams["value"] as? String ?: "0x0", data = txParams["data"] as? String ?: "0x", gas = null, // Let Portal handle gas estimation` gasPrice = null, maxFeePerGas = null, maxPriorityFeePerGas = null ) val send = portal.request( chainId = transaction.network, method = PortalRequestMethod.eth_sendTransaction, params = listOf(ethTransaction), options = RequestOptions(signatureApprovalMemo = "Yield transaction") // Optional signature approval memo to use for the request ) val txHash = send.result as? String ?: return false portal.yield.yieldxyz.track(transaction.id, txHash) return waitForReceipt(txHash, transaction.network) } suspend fun waitForReceipt(txHash: String, chainId: String, maxAttempts: Int = 30, delayMs: Long = 2000): Boolean { repeat(maxAttempts) { kotlinx.coroutines.delay(delayMs) val receiptResp = portal.request( chainId = chainId, method = PortalRequestMethod.eth_getTransactionReceipt, params = listOf(txHash) ) val receipt = receiptResp.result as? Map val status = receipt?.get("status") as? String if (status == "0x1") return true // Was mined if (status == "0x0") return false // Was not mined yet } return false } ``` ## Enum handling ### Unknown values Yield.xyz aggregates many protocols and onboards new ones regularly. Before 9.1.0, a response containing an enum value the SDK did not recognize decoded to `null` rather than failing — Gson's default enum adapter returns `null` for an unrecognized name, and that `null` was written into a non-null Kotlin field. The call still returned a successful `Result`, so there was nothing to catch in `onFailure`; the failure surfaced later, as a `NullPointerException` at the point where your code read the field. A single new value from an upstream provider could crash a screen rendering `getBalances` output, far from the call that fetched it. As of 9.1.0, unrecognized values deserialize to `UNKNOWN` instead. Your app keeps working when Yield.xyz onboards a new protocol, action type, or reward schedule, without waiting for an SDK upgrade — and the value you have to handle now shows up at the call site, in the type system, rather than as a late crash. Adding `UNKNOWN` is a source-breaking change. Any exhaustive `when` over one of the affected enums below will fail to compile until you add an `UNKNOWN` branch or an `else` branch. If you arrived here from a compiler error, this is the fix: ```kotlin theme={null} // Before 9.1.0 this compiled. Under 9.1.0 it does not — the compiler reports // "'when' expression must be exhaustive. Add the 'UNKNOWN' branch or an 'else' branch." when (component.yieldSource) { YieldXyzSource.staking -> showStakingBadge() YieldXyzSource.lending -> showLendingBadge() // … remaining values } // 9.1.0 — add a branch for values the SDK does not recognize. when (component.yieldSource) { YieldXyzSource.staking -> showStakingBadge() YieldXyzSource.lending -> showLendingBadge() // … remaining values YieldXyzSource.UNKNOWN -> showGenericBadge() } ``` Note the enum member casing: `YieldXyzSource` members are lowercase (`staking`, `lending`, `protocol_incentive`, …) while `UNKNOWN` is uppercase. That is not a typo in these docs — it is how the SDK declares them, and the same mix appears in most of the enums listed below. Write `UNKNOWN` in uppercase even when every other member of the same enum is lowercase. Prefer an explicit `UNKNOWN` branch over `else`. With `else`, the next value Portal adds falls into it silently; with an explicit `UNKNOWN`, you get a compiler error and a chance to handle the new value deliberately. ### Affected enums Thirteen public enums gained `UNKNOWN` in 9.1.0, grouped here by the file that declares them so you can find the one your compiler flagged: **From `YieldXyzGetYieldsResponse.kt`:** `YieldXyzRateType`, `YieldXyzSource`, `YieldXyzRewardSchedule`, `YieldXyzRewardClaiming`, `YieldXyzArgumentFieldName`, `YieldXyzArgumentFieldType` **From `YieldXyzEnterYieldResponse.kt`:** `YieldXyzActionIntent`, `YieldXyzActionType`, `YieldXyzActionExecutionPattern`, `YieldXyzActionStatus`, `YieldXyzActionTransactionStatus`, `YieldXyzActionTransactionType` **From `YieldXyzGetYieldsRequest.kt`:** `YieldXyzMechanicsType` All thirteen live in `io.portalhq.android.api.data.yieldxyz`. The reach extends past `discover`. `YieldXyzActionIntent`, `YieldXyzActionType`, `YieldXyzActionExecutionPattern`, and `YieldXyzActionStatus` are all fields on `YieldOpportunityDetails`, which is returned by the high-level [`deposit` and `withdraw`](#high-level-methods) methods. Code reading `result.yieldOpportunityDetails.status` is affected — note that these four fields are nullable, so an exhaustive `when` needs a `null` branch as well as an `UNKNOWN` branch: ```kotlin theme={null} when (result.yieldOpportunityDetails.status) { YieldXyzActionStatus.SUCCESS -> markComplete() YieldXyzActionStatus.PROCESSING, YieldXyzActionStatus.WAITING_FOR_NEXT, YieldXyzActionStatus.CREATED -> keepPolling() YieldXyzActionStatus.FAILED, YieldXyzActionStatus.CANCELED, YieldXyzActionStatus.STALE -> markFailed() YieldXyzActionStatus.UNKNOWN -> showRawStatus() null -> showPending() } ``` ### Handling UNKNOWN in your code 1. Treat `UNKNOWN` as "display it, don't act on it". Render a neutral label rather than hiding the row, so users still see their position. 2. Don't branch business logic on `UNKNOWN`. If a decision depends on knowing the exact source or status, read the raw response value rather than inferring from the enum. 3. Log occurrences along with the `yieldId`. A rising `UNKNOWN` rate is a signal to upgrade the SDK. ### Custom Gson instances Tolerance is implemented as a public Gson factory: ```kotlin theme={null} class YieldXyzUnknownEnumAdapterFactory : TypeAdapterFactory ``` `YieldXyzApi` registers it on its own Gson instance, so every SDK call is tolerant out of the box and you do not need to do anything. It matters only when you build your own Gson to deserialize Yield.xyz payloads — decoding a webhook body or a cached response, for example: ```kotlin theme={null} import com.google.gson.GsonBuilder import io.portalhq.android.api.data.yieldxyz.YieldXyzUnknownEnumAdapterFactory val gson = GsonBuilder() .registerTypeAdapterFactory(YieldXyzUnknownEnumAdapterFactory()) .create() ``` Without the factory registered, your own Gson maps an unrecognized value to `null`, which then violates the non-null Kotlin field it is assigned to and surfaces later as a `NullPointerException` — even though the SDK's own calls decode the same payload fine. Register the factory on any Gson instance you point at a Yield.xyz payload. The factory applies only to enums that declare an `UNKNOWN` constant; every other enum falls through to Gson's default adapter, so registering it is safe on a shared Gson instance. Alongside unrecognized strings, it also maps JSON `null` and non-string tokens (numbers, booleans, objects, arrays) to `UNKNOWN` rather than leaving a non-null field null. ### New lending source `YieldXyzSource` gained a `lending` member in 9.1.0. You encounter this enum on each entry of a discovered opportunity's `rewardRate.components` list, as `component.yieldSource`. The full member list as of 9.1.0: ```kotlin theme={null} enum class YieldXyzSource { staking, restaking, protocol_incentive, points, lending, lending_interest, mev, real_world_asset_yield, validator_commission, UNKNOWN } ``` No member carries a `@SerializedName` annotation or a constructor value, so the wire strings are the member names exactly as written above — `protocol_incentive` on the wire is `YieldXyzSource.protocol_incentive` in Kotlin. `lending` and `lending_interest` are separate members and Yield.xyz uses both. Treat them as distinct values rather than assuming one supersedes the other. ## Best Practices 1. **Always check yield availability** before attempting to enter positions 2. **Process transactions sequentially** as yield operations often require multiple steps and are dependent on previous transactions being mined successfully 3. **Handle network errors gracefully** and provide user feedback 4. **Monitor transaction status** and provide progress updates to users 5. **Validate user balances** before initiating yield operations ## Supported Networks The yield functionality supports various networks including: * Monad (`eip155:143`) * Monad Testnet (`eip155:10143`) * Arbitrum (`eip155:42161`) * Avalanche C (`eip155:43114`) * Base (`eip155:8453`) * Base Sepolia (`eip155:84532`) * Celo (`eip155:42220`) * Core (`eip155:1116`) * Ethereum (`eip155:1`) * Ethereum Sepolia (`eip155:11155111`) * Fantom (`eip155:250`) * Gnosis (`eip155:100`) * Harmony (`eip155:1666600000`) * Hyperevm (`eip155:999`) * Katana (`eip155:747474`) * Linea (`eip155:59144`) * Moonriver (`eip155:1285`) * Optimism (`eip155:10`) * Optimism Sepolia (`eip155:11155420`) * Plasma (`eip155:9745`) * Polygon (`eip155:137`) * Polygon Amoy (`eip155:80002`) * Sonic (`eip155:146`) * Unichain (`eip155:130`) * Viction (`eip155:88`) * zkSync (`eip155:324`) * Solana (`solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`) * Solana Devnet (`solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`) * Stellar (`stellar:pubnet`) * Stellar Testnet (`stellar:testnet`) * Tron (`tron:mainnet`) ## Next Steps * Learn about [managing wallet lifecycle states](./manage-wallet-lifecycle-states) * Explore [transaction simulation](./evaluate-a-transaction) * Check out [Portal API methods](./portal-api-methods) # Swap Tokens with 0x Source: https://docs.portalhq.io/sdks/android/guide/zerox Learn how to swap tokens using Portal's Android SDK with 0x integration. Portal's Android SDK provides token swapping functionality through the `portal.trading.zeroX` API. This integration allows you to retrieve swap quotes, inspect available liquidity sources, and execute token swaps using 0x. ## Overview Using the 0x integration, you can: * Fetch **indicative prices** for token swaps * Fetch **swap quotes** between supported tokens * Retrieve **available liquidity sources** * **Execute swaps** by signing and submitting transactions All swap execution is performed by submitting the transaction data returned by 0x using `portal.request`. ## Prerequisites Before using the 0x API, make sure you have: * A properly initialized Portal client * An active wallet with sufficient balance on the source network (see [Create a wallet](./create-a-wallet)) * 0x integration enabled in your Portal Dashboard (see [0x Integration](../../../integrations/Trading/zerox)) OR have a 0x API Key available *** ## Using a Custom 0x API Key (Optional) By default, Portal uses the 0x API Key that can be added through the Portal Dashboard to communicate with the 0x integration. If you have a 0x API key that you want to test with locally, you can optionally include it in the request via the `zeroXApiKey` parameter. ```kotlin theme={null} lifecycleScope.launch { val sourcesResult = portal.trading.zeroX.getSources( chainId = "eip155:1", zeroXApiKey = "YOUR_0X_API_KEY" ) sourcesResult.fold( onSuccess = { response -> val sources = response.data?.rawResponse?.sources ?: emptyList() println("Available sources: $sources") }, onFailure = { error -> println("Error: $error") } ) } ``` If you've configured your 0x API key in the Portal Dashboard, you can omit passing the `zeroXApiKey` parameter: ```kotlin theme={null} lifecycleScope.launch { val sourcesResult = portal.trading.zeroX.getSources( chainId = "eip155:1" ) sourcesResult.fold( onSuccess = { response -> val sources = response.data?.rawResponse?.sources ?: emptyList() println("Available sources: $sources") }, onFailure = { error -> println("Error: $error") } ) } ``` *** ## Getting a Price (Indicative) Use `portal.trading.zeroX.getPrice` to retrieve an **indicative price** for a token swap without generating executable transaction data. This method is useful for displaying prices, estimating swap outcomes, or building preview experiences without committing to a quote. ```kotlin theme={null} lifecycleScope.launch { val priceRequest = ZeroXPriceRequest( chainId = "eip155:1", buyToken = "USDC", sellToken = "ETH", sellAmount = "100000000000000" // 0.0001 ETH ) val priceResult = portal.trading.zeroX.getPrice( request = priceRequest ) priceResult.fold( onSuccess = { response -> val data = response.data?.rawResponse println("Price: ${data?.price ?: "N/A"}") println("Buy Amount: ${data?.buyAmount}") println("Liquidity Available: ${data?.liquidityAvailable ?: false}") }, onFailure = { error -> println("Error: $error") } ) } ``` *** ## Getting a Swap Quote Use `portal.trading.zeroX.getQuote` to fetch a swap quote from 0x with executable transaction data. ```kotlin theme={null} lifecycleScope.launch { val quoteRequest = ZeroXQuoteRequest( chainId = "eip155:1", buyToken = "USDC", sellToken = "ETH", sellAmount = "100000000000000" // 0.0001 ETH ) val quoteResult = portal.trading.zeroX.getQuote( request = quoteRequest ) quoteResult.fold( onSuccess = { response -> val quote = response.data?.rawResponse println("Quote: ${quote?.price ?: "N/A"}") println("Transaction: ${quote?.transaction}") }, onFailure = { error -> println("Error: $error") } ) } ``` *** ## Getting Liquidity Sources You can query available liquidity sources supported by 0x using `portal.trading.zeroX.getSources`. ```kotlin theme={null} lifecycleScope.launch { val sourcesResult = portal.trading.zeroX.getSources( chainId = "eip155:1" ) sourcesResult.fold( onSuccess = { response -> val sources = response.data?.rawResponse?.sources ?: emptyList() println("Available sources: $sources") }, onFailure = { error -> println("Error: $error") } ) } ``` *** ## Checking Quote Issues When getting a quote, you may encounter issues related to allowances, balances, or simulation. Always check for these before executing a swap: ```kotlin theme={null} lifecycleScope.launch { val quoteResult = portal.trading.zeroX.getQuote( request = quoteRequest ) quoteResult.fold( onSuccess = { response -> val issues = response.data?.rawResponse?.issues issues?.allowance?.let { allowance -> println("Allowance issue: actual=${allowance.actual}, spender=${allowance.spender}") } issues?.balance?.let { balance -> println("Balance issue: actual=${balance.actual}, expected=${balance.expected}") } issues?.simulationIncomplete?.let { incomplete -> if (incomplete) { println("Warning: Simulation incomplete") } } issues?.invalidSourcesPassed?.let { invalidSources -> if (invalidSources.isNotEmpty()) { println("Invalid sources: $invalidSources") } } }, onFailure = { error -> println("Error: $error") } ) } ``` *** ## Executing the Swap Once you receive a quote containing transaction data, execute the swap by sending the transaction through `portal.request`. The transaction data returned by 0x may include gas parameters such as `gas` or `gasPrice`. These fields are optional — you can omit them and let Portal estimate gas automatically, or include them if you prefer to use 0x's suggested values. ```kotlin theme={null} lifecycleScope.launch { // Get the quote val quoteRequest = ZeroXQuoteRequest( chainId = "eip155:1", buyToken = "USDC", sellToken = "ETH", sellAmount = "100000000000000" // 0.0001 ETH ) val quoteResult = portal.trading.zeroX.getQuote( request = quoteRequest ) quoteResult.fold( onSuccess = { response -> val quoteData = response.data if (quoteData == null) { println("Error: No data in response") return@fold } val transaction = quoteData.rawResponse?.transaction if (transaction == null) { println("Error: No transaction in quote response") return@fold } // Convert ZeroXTransaction to EthTransactionParam val ethTransaction = EthTransactionParam( from = transaction.from, to = transaction.to, gas = transaction.gas, gasPrice = transaction.gasPrice, maxFeePerGas = null, maxPriorityFeePerGas = null, value = transaction.value, data = transaction.data, nonce = null ) // Submit the transaction val sendTransactionResponse = portal.request( chainId = "eip155:1", method = PortalRequestMethod.eth_sendTransaction, params = listOf(ethTransaction), options = RequestOptions(signatureApprovalMemo = "Swap via 0x") // Optional signature approval memo to use for the request ) val transactionHash = sendTransactionResponse.result as? String if (transactionHash != null) { println("Transaction submitted: $transactionHash") } else { println("Error: Invalid response type for request") } }, onFailure = { error -> println("Error: $error") } ) } ``` *** ## Error Handling Handle errors gracefully when interacting with the 0x API: ```kotlin theme={null} lifecycleScope.launch { val sourcesResult = portal.trading.zeroX.getSources( chainId = "eip155:1" ) sourcesResult.fold( onSuccess = { response -> val sources = response.data?.rawResponse?.sources ?: emptyList() println("Success: $sources") }, onFailure = { error -> println("Error: $error") // Handle specific error types when (error) { is PortalHttpException -> { println("HTTP Error: ${error.message}") } is PortalHttpUnauthorizedException -> { println("Unauthorized: Check your API key") } else -> { println("Unknown error: ${error.message}") } } } ) } ``` *** ## Complete Swap Flow Example Here's a complete example of executing a token swap: ```kotlin theme={null} lifecycleScope.launch { val chainId = "eip155:1" try { // Step 1: Get sources val sourcesResult = portal.trading.zeroX.getSources( chainId = chainId ) sourcesResult.fold( onSuccess = { sourcesResponse -> val sources = sourcesResponse.data?.rawResponse?.sources ?: emptyList() println("Sources: $sources") // Step 2: Get quote val quoteRequest = ZeroXQuoteRequest( chainId = chainId, buyToken = "USDC", sellToken = "ETH", sellAmount = "100000000000000" // 0.0001 ETH ) val quoteResult = portal.trading.zeroX.getQuote( request = quoteRequest ) quoteResult.fold( onSuccess = { quoteResponse -> val quoteData = quoteResponse.data if (quoteData == null) { println("Error: No data in response") return@fold } val transaction = quoteData.rawResponse?.transaction if (transaction == null) { println("Error: No transaction in quote response") return@fold } // Step 3: Submit transaction val ethTransaction = EthTransactionParam( from = transaction.from, to = transaction.to, gas = transaction.gas, gasPrice = transaction.gasPrice, maxFeePerGas = null, maxPriorityFeePerGas = null, value = transaction.value, data = transaction.data, nonce = null ) val sendTransactionResponse = portal.request( chainId = chainId, method = PortalRequestMethod.eth_sendTransaction, params = listOf(ethTransaction), options = RequestOptions(signatureApprovalMemo = "Swap via 0x") // Optional signature approval memo to use for the request ) val transactionHash = sendTransactionResponse.result as? String if (transactionHash != null) { println("Transaction submitted: $transactionHash") } else { println("Error: Invalid response type for request") } }, onFailure = { error -> println("Error getting quote: $error") } ) }, onFailure = { error -> println("Error getting sources: $error") } ) } catch (error: Throwable) { println("Error: $error") } } ``` *** ## Supported Networks The `portal.trading.zeroX` API supports a predefined set of EIP-155 networks. Requests using unsupported chains will fail. | Network | EIP-155 Chain ID | | ---------- | ---------------- | | Ethereum | `eip155:1` | | Optimism | `eip155:10` | | BSC | `eip155:56` | | Unichain | `eip155:130` | | Polygon | `eip155:137` | | Worldchain | `eip155:480` | | Mantle | `eip155:5000` | | Base | `eip155:8453` | | Monad | `eip155:143` | | Mode | `eip155:34443` | | Arbitrum | `eip155:42161` | | Avalanche | `eip155:43114` | | Ink | `eip155:57073` | | Linea | `eip155:59144` | | Berachain | `eip155:80094` | | Blast | `eip155:81457` | | Scroll | `eip155:534352` | 0x supports mainnet networks only — calls against testnets surface as a `ZEROX_GET_QUOTE_ERROR` from the native SDK. *** ## Next Steps * Learn how to [sign Ethereum transactions](./sign-a-transaction) * Explore how to [send tokens](./send-tokens) * Check out [Portal API methods](./portal-api-methods) # Reference Source: https://docs.portalhq.io/sdks/android/reference Read the reference documentation for the Portal Android SDK. Read the reference documentation for the Portal Android SDK [here](https://portal-hq.github.io). # Native Platform Channels (Android) Source: https://docs.portalhq.io/sdks/flutter/guide/android Advanced guide for integrating Portal into your Flutter app using native platform channels. Most users should use the [Portal Flutter SDK](./getting-started) directly. This guide is for advanced use cases requiring custom platform channel integration. In order to integrate **Portal** into your **Flutter** App using native platform channels, you will need to write custom platform-specific code.\\ On the client side, [`MethodChannel`](https://api.flutter.dev/flutter/services/MethodChannel-class.html?_gl=1*4sycbc*_ga*MTE0MjAwMDc0MS4xNzI2MDk0ODMw*_ga_04YGWK0175*MTcyNjE2NTI2Ny4yLjEuMTcyNjE2NTQ3NS4wLjAuMA..) enables sending messages that correspond to method calls. On the platform side, `MethodChannel` on Android ([MethodChannelAndroid](https://api.flutter.dev/javadoc/io/flutter/plugin/common/MethodChannel.html?_gl=1*wlfjam*_ga*MTE0MjAwMDc0MS4xNzI2MDk0ODMw*_ga_04YGWK0175*MTcyNjIzODQ5NC4zLjAuMTcyNjIzODQ5NC4wLjAuMA..)) enable receiving method calls and sending back a result. These classes allow you to develop a platform plugin with very little 'boilerplate' code. You can check the Flutter official documentation for [platform channels](https://docs.flutter.dev/platform-integration/platform-channels).\\ You can check our [Portal Flutter ](https://github.com/portal-hq/portal_flutter)example. ## Add Portal implementation for Android: 1. Open the Android host portion of your **Flutter** app in **Android Studio**. 2. Add `Portal Android SDK` to the Android project, you can follow this [Android setup guide](../../android/guide/create-a-wallet). 3. Open `MainActivity.kt` located under **android > app > src > main > kotlin** in the project navigation. The client and host sides of a channel are connected through a channel name passed in the channel constructor. All channel names used in a single app must be unique; prefix the channel name with a unique 'identifier', for example: `your.bundle.identifier/portal`. Below the channel name and some method names are defined. ```kotlin theme={null} object Constants { const val FLUTTER_CHANNEL_NAME = "your.bundle.identifier/portal" const val METHOD_INITIALIZE_PORTAL = "initializePortal" const val METHOD_CREATE_WALLET = "createWallet" } ``` Now inside the `MainActivity.kt`, configure channel and methods handling like this. ```kotlin theme={null} class MainActivity : FlutterActivity() { private lateinit var portal: Portal override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) MethodChannel(flutterEngine.dartExecutor.binaryMessenger, Constants.FLUTTER_CHANNEL_NAME) .setMethodCallHandler { call, result -> handleFlutterMethodCall(call, result) } } private fun handleFlutterMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { Constants.METHOD_INITIALIZE_PORTAL -> initializePortal(call.arguments, result) } } private fun initializePortal(arguments: Any?, result: MethodChannel.Result) { if (arguments == null || arguments !is String || arguments.isBlank()) { result.error("FAILED", "Missing argument apiKey", null) return } try { portal = Portal( apiKey = arguments, featureFlags = FeatureFlags(useEnclaveMPCApi = true) ) result.success("Portal initialized") } catch (e: Exception) { result.error("FAILED", e.message, null) } } } ``` Then define a callback to handle any method invocation that comes from the Flutter side and they Switch on those Methods then execute the proper Kotlin implementation for each one and reply back to Flutter using a regular callback of type `FlutterResult` which is expected to be passed from Flutter side as a completion handler. You will also need to define this implementation in your native iOS code as well for the code to work on both iOS and Android. Refer to [this guide](./ios) for iOS specific instructions. ### Calling platform-specific code using platform channels Now Portal Android implementation is ready for Flutter, Let's create the Flutter platform client:\ We will use the same channel name we defined above to initialize `MethodChannel` ```dart theme={null} static const platform = MethodChannel('your.bundle.identifier/portal'); ``` Next, invoke a method on the method channel, specifying the concrete method to call using the `String` identifier `initializePortal`. The call might fail, so wrap the `invokeMethod` call in a try-catch statement. ```dart theme={null} Future initializePortal(String apiKey) async { String message; try { final result = await platform.invokeMethod('initializePortal', apiKey); message = 'Success with message ${result?['message']}'; } on PlatformException catch (e) { message = "Failed to initialize portal with error: '${e.message}'."; } } ``` Finally, your Flutter function `initializePortal` to initialize portal is ready to be called. You can call it from Flutter side passing the `apiKey to initialize Portal.` # Back up a wallet Source: https://docs.portalhq.io/sdks/flutter/guide/back-up-a-wallet This guide will walk you through how to create a backup of a Portal client's wallet. ## Portal-Managed Backups Portal lets you securely back up your users' MPC wallets so they can recover their wallets even if their device is lost or damaged. By default, Portal encrypts and stores both backup shares ("Portal-Managed Backups"): 1. The **client backup share** is encrypted on the user's device, with the encryption key stored using their chosen backup method (Google Drive, iCloud, Password, Passkey, or Firebase Auth). The encrypted share is then stored by Portal. 2. The **custodian backup share** is encrypted and stored by Portal, with the encryption key stored in our KMS infrastructure. By default, Portal manages storing both the encrypted client backup share and the custodian backup share for you. If you prefer to store and manage the backup shares in your own infrastructure instead of using Portal-Managed Backups, see our [Self-Managed Backups](../../../resources/self-managed-backups) guide. Both the client backup share and the custodian backup share are necessary to recover a Portal wallet. ## Backup Methods You can choose one or more backup methods for storing the encryption key for the client backup share. ### Passkey + Enclave Allow customers to create a native passkey on their device that is used to authenticate into a secure enclave that holds the encryption key for the user. Customer's passkeys are backed up to the native cloud storage for their device. #### Implementation Requirements 1. Configure passkey storage with a relying party. 2. Set up your associated domain correctly in your app. #### Use Portal as your relying party 1. Add `portalhq.io` as a web credential domain in your app. 2. Share your app bundle id with the Portal Team. #### Use your own domain as the relying party Ensure you have set up your associated domain correctly in your app and that you are serving an aasa file from whatever your relying party domain is set to. You will need to be sure you have the `webcredential` field set properly for your app in your aasa file. #### Relying party A relying party is a trusted domain that is tied to the public key credentials of your users for their passkey. We offer the option to use `portalhq.io` as your relying party domain. It requires you to add `portalhq.io` as an Associated Domain in your application and share your team id + application bundle id. If you already have your domain as a `webcredential` for your application then you can simply pass in your domain as the relying party and everything should work. ```dart theme={null} // Configure passkey storage. await portal.configurePasskeyStorage( relyingPartyId: 'portalhq.io', relyingPartyOrigins: ['https://portalhq.io'], ); // Run backup. final response = await portal.backupWallet( method: PortalBackupMethod.passkey, ); ``` ```dart theme={null} const method = PortalBackupMethod.passkey; await portal.configurePasskeyStorage( relyingPartyId: 'portalhq.io', relyingPartyOrigins: ['https://portalhq.io'], ); // Run backup. final response = await portal.backupWallet(method: method); try { // Store the encrypted client backup share on your API. await yourApi.storeEncryptedClientBackupShare( userId: userId, backupMethod: 'passkey', cipherText: response.cipherText, ); } catch (_) { // Storage step failed — release the pending callback so the user can retry. await response.discard(); rethrow; } // Storage succeeded — mark the backup complete with Portal. await response.confirm(); // ✅ The user has now backed up with passkey successfully ``` ### Password/PIN Allow customers to create a password/pin. Customers can either remember the password or store it in a password storage manager. #### Implementation Requirements 1. Create a UI for password input. 2. Enforce password requirements. Customer can choose between password, PIN code, passcode, or any other text-based input. 3. If user forgets password there are no additional recovery options. ```dart theme={null} // Run backup. final response = await portal.backupWallet( method: PortalBackupMethod.password, password: 'THE-USER-PASSWORD', ); ``` ```dart theme={null} const method = PortalBackupMethod.password; // Run backup. final response = await portal.backupWallet( method: method, password: 'THE-USER-PASSWORD', ); try { // Store the encrypted client backup share on your API. await yourApi.storeEncryptedClientBackupShare( userId: userId, backupMethod: 'password', cipherText: response.cipherText, ); } catch (_) { // Storage step failed — release the pending callback so the user can retry. await response.discard(); rethrow; } // Storage succeeded — mark the backup complete with Portal. await response.confirm(); // ✅ The user has now backed up with password successfully ``` ### Firebase Auth Backup Allow customers to use their existing Firebase Authentication to authenticate into a secure enclave that holds the encryption key for the user. The Portal SDK leverages Firebase ID tokens to securely store and retrieve encryption keys from the secure enclave. This is ideal if your app already uses Firebase Auth — no additional authentication method is required from your users. See the [Firebase Auth Backup setup guide](../../../resources/backup-options/firebase-byo-auth) for prerequisites and Firebase project configuration. #### Configure Firebase storage After initializing your Portal instance, call `configureFirebaseStorage` with a `getToken` callback that returns a fresh Firebase ID token: ```dart theme={null} import 'package:firebase_auth/firebase_auth.dart'; import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Configure Firebase as a backup method. await portal.configureFirebaseStorage( getToken: () async { final user = FirebaseAuth.instance.currentUser; if (user == null) return null; return await user.getIdToken(true); }, ); ``` The user must be signed in to Firebase before performing any backup or recovery operations. If no Firebase user is signed in, the `getToken` callback returns `null` and the operation will fail. ```dart theme={null} // Ensure user is signed in to Firebase, then run backup. final response = await portal.backupWallet( method: PortalBackupMethod.firebase, ); ``` ```dart theme={null} const method = PortalBackupMethod.firebase; // Ensure user is signed in to Firebase, then run backup. final response = await portal.backupWallet(method: method); try { // Store the encrypted client backup share on your API. await yourApi.storeEncryptedClientBackupShare( userId: userId, backupMethod: 'firebase', cipherText: response.cipherText, ); } catch (_) { // Storage step failed — release the pending callback so the user can retry. await response.discard(); rethrow; } // Storage succeeded — mark the backup complete with Portal. await response.confirm(); ``` ### iCloud See the docs on how to configure [iCloud](../../../resources/backup-options/icloud). iCloud backup is only available on iOS devices. On Android, use Google Drive or Passkey backup instead. For the `iCloud` action handling: ```dart theme={null} // Configure iCloud storage (iOS only). await portal.configureICloudStorage(); // Run backup. final response = await portal.backupWallet( method: PortalBackupMethod.iCloud, ); ``` ```dart theme={null} const method = PortalBackupMethod.iCloud; await portal.configureICloudStorage(); // Run backup. final response = await portal.backupWallet(method: method); try { // Store the encrypted client backup share on your API. await yourApi.storeEncryptedClientBackupShare( userId: userId, backupMethod: 'iCloud', cipherText: response.cipherText, ); } catch (_) { // Storage step failed — release the pending callback so the user can retry. await response.discard(); rethrow; } // Storage succeeded — mark the backup complete with Portal. await response.confirm(); // ✅ The user has now backed up with iCloud successfully ``` ### Google Drive See the docs on how to configure [Google Drive](../../../resources/backup-options/gdrive). #### Choosing a backup option Pick where the encrypted backup is written via the `backupOption` parameter on `configureGoogleStorage`: | `PortalGDriveBackupOption` | Folder location | Visible to user? | Recommended for | | --------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------ | | `appDataFolder` | Hidden, app-scoped Drive AppData folder. | No | **New integrations** — invisible and tamper-resistant. | | `appDataFolderWithFallback` | Writes to AppData; falls back to the SDK's built-in custom folder on read miss. `folderName` is ignored. | No (write); Yes (fallback read) | Migrating from a previous `customFolder` setup. | | `customFolder` | User-visible folder named `folderName` (default `_PORTAL_MPC_DO_NOT_DELETE_`). | Yes | Apps already shipped with the legacy custom folder. | After initializing Portal, configure Google Drive storage: ```dart theme={null} // Recommended for new integrations: hidden AppData folder. await portal.configureGoogleStorage( clientId: 'your-google-client-id', backupOption: PortalGDriveBackupOption.appDataFolder, ); ``` For the `GoogleDrive` action handling: ```dart theme={null} // Run backup. final response = await portal.backupWallet( method: PortalBackupMethod.googleDrive, ); ``` ```dart theme={null} const method = PortalBackupMethod.googleDrive; // Run backup. final response = await portal.backupWallet(method: method); try { // Store the encrypted client backup share on your API. await yourApi.storeEncryptedClientBackupShare( userId: userId, backupMethod: 'googleDrive', cipherText: response.cipherText, ); } catch (_) { // Storage step failed — release the pending callback so the user can retry. await response.discard(); rethrow; } // Storage succeeded — mark the backup complete with Portal. await response.confirm(); // ✅ The user has now backed up with Google Drive successfully ``` #### Migrating existing users (AppData with fallback) If your app previously shipped with the default `customFolder` configuration, switch to `appDataFolderWithFallback` so new backups land in AppData while existing users can still recover from the legacy custom folder: ```dart theme={null} await portal.configureGoogleStorage( clientId: 'your-google-client-id', backupOption: PortalGDriveBackupOption.appDataFolderWithFallback, ); ``` #### Custom user-visible folder (default / legacy) This is the behavior you get when `backupOption` is omitted — preserved for backward compatibility. You can still customize the folder name via `folderName`: ```dart theme={null} await portal.configureGoogleStorage( clientId: 'your-google-client-id', folderName: 'MyAppBackups', ); ``` ## Checking Backup Status You can check if a wallet has been backed up: ```dart theme={null} final isBackedUp = await portal.isWalletBackedUp(); if (isBackedUp) { print('Wallet is backed up'); } else { print('Wallet needs to be backed up'); } ``` ## Platform Support Matrix | Backup Method | Android | iOS | | ------------- | ------- | --- | | Password | ✓ | ✓ | | Passkey | ✓ | ✓ | | Google Drive | ✓ | ✓ | | iCloud | - | ✓ | | Firebase Auth | ✓ | ✓ | **Related Documentation** * [Backup options](../../../resources/backup-options/gdrive) * [Firebase Auth Backup option](../../../resources/backup-options/firebase-byo-auth) * [backupWallet function reference](../reference/backupwallet) * [configureFirebaseStorage function reference](../reference/configurefirebasestorage) * [configureGoogleStorage function reference](../reference/configuregooglestorage) * [configurePasskeyStorage function reference](../reference/configurepasskeystorage) * [setPassword function reference](../reference/setpassword) * [isWalletBackedUp function reference](../reference/iswalletbackedup) # Enhance your wallets' security with Blockaid Source: https://docs.portalhq.io/sdks/flutter/guide/blockaid Learn how to integrate real-time transaction, address, token, and URL security scanning using Portal's Flutter SDK with Blockaid. Portal's Flutter SDK integrates with Blockaid to provide real-time security scanning for transactions, addresses, tokens, and URLs. This integration helps detect malicious activity, phishing attempts, scam tokens, and suspicious interactions before users sign or submit transactions. ## Overview The Blockaid integration enables you to: * **Scan transactions** before signing or submission (EVM, Solana) * **Scan addresses** for known malicious actors or compromised contracts * **Validate tokens** to detect scams or security risks * **Verify URLs** to prevent phishing attacks ## Prerequisites Before using Blockaid security scanning, ensure you have: * A properly initialized Portal client * Blockaid integration enabled in your Portal Dashboard (see [Blockaid Integration](../../../integrations/Security/blockaid)) ## Scanning EVM Transactions Use `scanEvmTx` to analyze Ethereum transactions before signing or broadcasting them. This method scans EVM transactions for malicious contract interactions, risky approvals, phishing attempts, and other on-chain security threats. ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); Future scanEvmTransaction() async { final response = await portal.security.blockaid.scanEvmTx( chain: 'eip155:1', fromAddress: '0x7C01728004d3F2370C1BBC36a4Ad680fE6FE8729', toAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', txData: '0x095ea7b3...', value: '0x0', gas: '0x2dc6c0', gasPrice: '0x2dc6c0', options: ['simulation', 'validation'], ); print('Blockaid EVM scan response: $response'); } ``` *** ## Scanning Solana Transactions Use `scanSolanaTx` to analyze Solana transactions before signing. This method detects malicious program invocations, suspicious token movements, and other Solana-specific risks. ```dart theme={null} Future scanSolanaTransaction() async { final response = await portal.security.blockaid.scanSolanaTx( accountAddress: '86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY', chain: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', transactions: const [ 'AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQADCQkVR3SiiKbW0l4c3NBsEn6+zn1o0YsyypPwN0GUhg4K5HK0Tb5GckDLYW+MsovQASt5EZ3bSH3nluRJAE69H61w0BRUDTrpYQcXosUun6/z2BROkRoH/1bL7KLU9s4lCav6k3ZZgV6qeZFwu4pu89WoIGaqUxG4C93XwVmmDy81v8qBaCSP4/UZfdo3q1bud/W+ixymkH8IMe0laQZYrSx4Uhyxec67hYm1VqLV7JTSSYaC/fm7KvWtZOSRzEFT2gMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAT4tlY/P4mFG1wDJl0ektVggHiZf73lTlHBVJ3fK0nDoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANG5fPtlMEOI/eXV7aPDlpcdLUKm8L3VoW6k/oJlCNLaBQYABQLARQQABgAJAwYAAAAAAAAABzwACQoLCwgyMzQMNQ0ONjcPEDg5EgETFBUWOhEXGBkaGxwdHh8gISI7IyQlJicCKCkqKywtAwQuLzAxPBFVCg8JAQcHBgYBAAAAAwHwCgYBExUbBgICAAAPAwIAAAYBISMoEQQBGQAPAwIAAAYBLjA2DwMCAAAGAgIAAAAIBgYICAADAQkGCQUFBgACBQAEBwEAAAgCAAUMAgAAADwaAAAAAAAABgAFBGDMBQAEPPm21Wu6wrmHu23/ZFNIumpp+ADooZjd4JQgvjnBxkUJAgEDBqWqCgmmCAUIBwu1tp+gcP/+Ri3C1tRXUbPdgqo6rVsj/qnqC959wTdC/mRARysLz9HS09TW19jZ2tsC1QYsNrdxMcm5Nq5FXZrM0IXpEA+ApFa+pz/JvkLz0+2vnwuztLW2t7i5uru8vgAPvBv8VUeRwDy9yD1NHIH5Ji6ZA+zrmpHejKOz4MP8SwrKy8zNzs/S09TVAdY=', ], encoding: 'base64', method: 'signAndSendTransaction', options: ['simulation', 'validation'], ); print('Blockaid Solana scan response: $response'); } ``` *** ## Scanning Addresses Use `scanAddress` to analyze a single address for known security risks. This method can be used for both EVM and Solana addresses and detects malicious contracts, compromised wallets, sanctioned addresses, and other flagged entities. ### EVM Address Scan ```dart theme={null} Future scanEvmAddress() async { final response = await portal.security.blockaid.scanAddress( chain: 'eip155:1', address: '0x31c05d73f2333b5a176cfdbb7c5ef96ec7bb04ac', ); print('Blockaid EVM address scan response: $response'); } ``` ### Solana Address Scan ```dart theme={null} Future scanSolanaAddress() async { final response = await portal.security.blockaid.scanAddress( chain: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', address: 'BBQUMqaFELxTFh8r1xSttYMHX6ZKzLWhsmGod2vuxgEc', ); print('Blockaid Solana address scan response: $response'); } ``` *** ## Scanning Tokens Use `scanTokens` to analyze multiple tokens in a single request for known security risks. This method detects scam tokens, honeypots, compromised contracts, and other malicious token behavior. ```dart theme={null} Future scanTokens() async { final response = await portal.security.blockaid.scanTokens( chain: 'eip155:1', tokens: const [ '0x66587563e933bbf3974b89156b47bb82b921eb35', '0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d', ], ); print('Blockaid token scan response: $response'); } ``` *** ## Scanning URLs Use `scanUrl` to detect phishing sites and malicious domains before users navigate to them. This method helps protect users from social engineering attacks and malicious off-chain activity. ```dart theme={null} Future scanUrl() async { final response = await portal.security.blockaid.scanUrl( url: 'https://ethlen.com', ); print('Blockaid URL scan response: $response'); } ``` *** ## Error Handling All Blockaid scan methods are async functions that throw a `PortalException` on failure. Wrap calls in a `try`/`catch` block to handle errors gracefully. ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; try { final response = await portal.security.blockaid.scanEvmTx( chain: 'eip155:1', fromAddress: '0x7C01728004d3F2370C1BBC36a4Ad680fE6FE8729', toAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', ); // Handle successful response } on PortalException catch (e) { print('Blockaid scan failed: ${e.code} - ${e.message}'); } ``` *** ## Next Steps * Learn about [signing transactions](./sign-a-transaction) * Explore [transaction evaluation](./evaluate-a-transaction) * Check out the [Blockaid Integration setup](../../../integrations/Security/blockaid) # Configure log level Source: https://docs.portalhq.io/sdks/flutter/guide/configure-log-level Control the verbosity of SDK log output at runtime using PortalLogLevel. By default, the Portal SDK emits no logs. You can enable logging at any verbosity level to help debug integration issues or monitor SDK behavior in development. ## Log levels The `PortalLogLevel` enum defines five levels. Each level includes all levels above it in severity. | Level | What is logged | | ---------------------- | --------------------------------------------------------------------------------------- | | `PortalLogLevel.none` | Nothing. This is the default. | | `PortalLogLevel.error` | Failures only — failed transactions, network errors, binary crashes. | | `PortalLogLevel.warn` | Unexpected but non-fatal conditions — deprecated usage, retries, slow responses. | | `PortalLogLevel.info` | Normal operational milestones — signing started, share generated, connection opened. | | `PortalLogLevel.debug` | Everything, including internals — request/response payloads, timing, state transitions. | ## Set the log level ### At initialization Pass `logLevel` when initializing your `Portal` instance. ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); await portal.initialize( apiKey: 'YOUR_CLIENT_API_KEY', logLevel: PortalLogLevel.debug, ); ``` ### At runtime Call `portal.setLogLevel()` at any time. The change takes effect immediately across all SDK components — no reinitialization needed. ```dart theme={null} // Enable debug logging during development await portal.setLogLevel(PortalLogLevel.debug); // Later, reduce to errors only await portal.setLogLevel(PortalLogLevel.error); ``` Set the log level before calling any other SDK methods to capture all output from the start. ## Recommended levels by environment * **Development**: `PortalLogLevel.debug` — see all SDK activity while building your integration. * **QA / staging**: `PortalLogLevel.info` or `PortalLogLevel.warn` — surface operational milestones and anomalies without noise. * **Production**: `PortalLogLevel.none` (default) — no logs emitted. Use `PortalLogLevel.error` if you want to forward failures to a crash reporter. Do not use `PortalLogLevel.debug` in production. Debug output includes request payloads and internal state that may contain sensitive data. **Related Documentation** * [setLogLevel reference](../reference/setloglevel) # Create a wallet Source: https://docs.portalhq.io/sdks/flutter/guide/create-a-wallet Once you have a Portal instance, you can now create a wallet for your user. If you don't have a `Portal` instance already, we recommend reading [this page](./) first. In the MPC wallet creation process, two sets of key shares are generated. The **signing key shares** are used for signing transactions, and the **backup key shares** are used for recovery if the device storing a signing key share is lost. The **`portal.createWallet`** command initiates the MPC process to create a set of shares. The users' signing shares are automatically stored in the user's secure phone storage. ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Create the wallet. // Obtain the Ethereum and Solana addresses of the user's new wallet. final addresses = await portal.createWallet(); print('My Portal EVM address: ${addresses.ethereum}'); print('My Portal Solana address: ${addresses.solana}'); ``` Want faster wallet creation? Enable the [`usePreGeneratedWallet`](./feature-flags#usepregeneratedwallet) feature flag—no changes to the code above are needed. Be sure to use a **Production** API key when creating clients for production. Read more details about going to production [here](../../../resources/going-to-production). **WARNING**: To create a wallet with the Portal SDK, your device must be configured to use passcode authentication. If you change your passcode, your Portal wallet will continue to operate as expected. However, if you disable passcode authentication after running the `createWallet` function, you will be required to execute the `recoverWallet` function before you can continue using your Portal wallet. ## Checking for Existing Wallets Before creating a new wallet, you may want to check if one already exists: ```dart theme={null} // Check if a wallet exists on the device final exists = await portal.doesWalletExist(); if (exists) { // Get existing addresses final result = await portal.getAddresses(); if (result.hasWallet) { print('Existing wallet found'); print('EVM address: ${result.addresses.ethereum}'); print('Solana address: ${result.addresses.solana}'); } } else { // Create a new wallet final addresses = await portal.createWallet(); print('New wallet created'); } ``` ## Getting a Specific Chain Address You can also get the address for a specific chain: ```dart theme={null} // Get the address for Ethereum mainnet final ethAddress = await portal.getAddress('eip155:1'); // Get the address for Solana mainnet final solAddress = await portal.getAddress('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'); ``` Now that you've created your user's wallet, let's try to send a transaction with it! **Related Documentation** * [createWallet function reference](../reference/createwallet) * [getAddresses function reference](../reference/getaddresses) * [doesWalletExist function reference](../reference/doeswalletexist) # Manage Token Delegations Source: https://docs.portalhq.io/sdks/flutter/guide/delegations Learn how to approve, revoke, and manage token delegations using Portal's Flutter SDK. Portal's Flutter SDK provides token delegation capabilities through the `portal.delegations` API. This enables approving token spending, revoking approvals, checking delegation status, and transferring tokens as a delegate on both EVM and Solana chains. ## Overview The delegations functionality allows you to: * **Approve** other addresses to spend tokens on behalf of your wallet * **Revoke** existing delegations to remove spending permissions * **Check status** of active delegations and balances * **Transfer tokens** as a delegate from another address ## Prerequisites Before using delegation operations, ensure you have: * A properly initialized Portal client * An active wallet with tokens on the target network (see [Create a wallet](./create-a-wallet)) * Understanding of [token delegations concepts](/resources/delegations) Delegations apply to ERC-20 tokens (EVM) and SPL Tokens (Solana) only. Native assets like ETH, MON, and SOL cannot be delegated — they have no on-chain `approve` / `transferFrom` (or SPL delegate) semantics. Calls using a native asset identifier will be rejected. See [Delegations](/resources/delegations#what-are-token-delegations) for the protocol-level reason and workarounds. All examples below assume Portal has already been initialized with `await portal.initialize(apiKey: 'YOUR_API_KEY')`. See [Getting Started](./getting-started) for details. ## High-Level Methods Use `approveAndSubmit`, `revokeAndSubmit`, and `transferAndSubmit` when you want one call for the whole flow: build the delegation transaction(s), then sign and broadcast each one in order and collect the resulting hashes. The wallet's signer is wired up for you, and EVM and Solana chains are routed to the right RPC method automatically, so the common case needs no configuration at all. `approveAndSubmit`, `revokeAndSubmit`, and `transferAndSubmit` broadcast each transaction and return as soon as it is accepted by the network. They **do not wait for on-chain confirmation**. A returned hash means the transaction was submitted, not that it succeeded — an approval can still revert. If your flow depends on the delegation being active, wait for the receipt yourself, or poll `getStatus` before proceeding. ### Signatures ```dart theme={null} Future> approveAndSubmit({ required String chain, required String token, required String delegateAddress, required String amount, void Function(DelegationSubmitProgress)? onProgress, }); Future> revokeAndSubmit({ required String chain, required String token, required String delegateAddress, void Function(DelegationSubmitProgress)? onProgress, }); Future> transferAndSubmit({ required String chain, required String token, required String fromAddress, required String toAddress, required String amount, void Function(DelegationSubmitProgress)? onProgress, }); ``` Each takes the same parameters as its low-level counterpart — see [EVM Approval](#evm-approval), [EVM Revoke](#evm-revoke), and [EVM Transfer From](#evm-transfer-from) below — plus an optional `onProgress`. Failures throw a `PortalException`, so wrap calls in `try` / `catch`. ### Progress `onProgress` is optional. When set, it is called **twice per transaction** — once with `signing` before signing begins, and once with `submitted` after the transaction is broadcast. For a batch of *n* transactions it is called *2n* times. `DelegationSubmitProgress` carries: | Field | Type | Description | | ------- | ---------------------- | ----------------------------------------------------------------------- | | `step` | `DelegationSubmitStep` | `signing` or `submitted`. | | `index` | `int` | The 0-based index of this transaction in the sequence. | | `total` | `int` | The total number of transactions in the sequence. | | `hash` | `String?` | `null` on `signing`, and the broadcast transaction hash on `submitted`. | `DelegationSubmitStep` has exactly two values — `signing` and `submitted`. There is no confirming or confirmed step, because nothing is awaited on-chain. ### Return value All three return `List` — one transaction hash per broadcast transaction, in submission order. Most delegation flows produce a single transaction, so the list usually has one element. Transactions are submitted **sequentially**, not batched into one RPC call: if the API returns multiple payloads, the second is only sent after the first has been broadcast. There is no status field and no partial-success concept: a hash is present because the network accepted the transaction, and nothing beyond that has been checked. ### Example (approve and submit) ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); try { final hashes = await portal.delegations.approveAndSubmit( chain: 'eip155:11155111', // Sepolia testnet token: 'USDC', delegateAddress: '0xa944e86eb36f039becd1843132347eb5b8501562', amount: '0.01', onProgress: (progress) { print('${progress.step.value} ${progress.index + 1}/${progress.total}'); }, ); print('Approved. Hashes: $hashes'); } on PortalException catch (e) { print('approveAndSubmit failed: ${e.message}'); } ``` ### Example (revoke and submit) ```dart theme={null} try { final hashes = await portal.delegations.revokeAndSubmit( chain: 'eip155:11155111', token: 'USDC', delegateAddress: '0xa944e86eb36f039becd1843132347eb5b8501562', ); print('Revoked. Hashes: $hashes'); } on PortalException catch (e) { print('revokeAndSubmit failed: ${e.message}'); } ``` ### Example (transfer as a delegate) The wallet calling `transferAndSubmit` must already be an approved delegate of `fromAddress`. ```dart theme={null} try { final hashes = await portal.delegations.transferAndSubmit( chain: 'eip155:11155111', token: 'USDC', fromAddress: '0xowner...', // the wallet that approved the delegation toAddress: '0xrecipient...', amount: '0.01', ); print('Transferred. Hashes: $hashes'); } on PortalException catch (e) { print('transferAndSubmit failed: ${e.message}'); } ``` ### Example (Solana) The same three methods work on Solana — pass a `solana:` CAIP-2 chain ID and the SDK signs and submits via `sol_signAndSendTransaction` instead of `eth_sendTransaction`: ```dart theme={null} final hashes = await portal.delegations.approveAndSubmit( chain: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', // Solana devnet token: 'USDC', delegateAddress: 'DELEGATE_SOLANA_ADDRESS', amount: '0.01', ); ``` ### Errors All failures throw a `PortalException`. Catch it with `on PortalException`: | Code | When | | -------------------------------------- | -------------------------------------------------------------------- | | `NOT_INITIALIZED` | `portal.initialize()` has not completed yet. | | `DELEGATION_APPROVE_AND_SUBMIT_ERROR` | `approveAndSubmit` failed while building, signing, or broadcasting. | | `DELEGATION_REVOKE_AND_SUBMIT_ERROR` | `revokeAndSubmit` failed while building, signing, or broadcasting. | | `DELEGATION_TRANSFER_AND_SUBMIT_ERROR` | `transferAndSubmit` failed while building, signing, or broadcasting. | The `message` carries the underlying reason. Two come from the native SDK itself: | Message | When | | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `No transactions in delegation response.` | The API returned no transactions to sign, so there is nothing to submit. | | `Invalid transaction hash returned from signAndSendTransaction at index N for chain C.` | A transaction was signed but no usable hash came back. The message names which transaction in the sequence it was. | Most failures instead surface the API's own error message, such as: | Message | When | | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `USDC token account does not exist.` | Revoking or transferring for a token the wallet has never held. `approve` does not raise this — it adds a token-account creation instruction to the transaction it returns. | | `No active delegation to revoke` | `revoke` when the token account has no delegate set. | | `No delegation active` | `transferFrom` when the owner has not approved a delegation. | | `Authenticated client is not the delegate` | `transferFrom` from a wallet other than the approved delegate. | | `Transfer amount exceeds delegated amount` | `transferFrom` for more than the approved allowance. | | `Owner has insufficient USDC balance` | `transferFrom` for more than the owner actually holds. `approve` does not check balance — you may approve more than you hold. | | `Delegation is only supported on Solana mainnet and devnet` | A `solana:` chain ID other than mainnet or devnet. | The submit methods can also fail with the same network and decoding errors the low-level methods return. ```dart theme={null} try { final hashes = await portal.delegations.approveAndSubmit( chain: 'eip155:11155111', token: 'USDC', delegateAddress: '0x1a3eda7eb7d13e60e638711c580490c19e164fee', amount: '0.01', ); print('Submitted hashes: $hashes'); } on PortalException catch (e) { print('approveAndSubmit failed (${e.code}): ${e.message}'); } ``` *** ## Low-level methods The sections below are the manual path: `approve`, `revoke`, `transferFrom`, and `getStatus` return unsigned transactions and leave signing and broadcasting to you. You receive EVM transaction objects or Solana-encoded payloads and call `portal.request` with the right method (`eth_sendTransaction` or `sol_signAndSendTransaction`) yourself. Use them when you need to inspect, modify, batch, or route the transactions yourself. Otherwise prefer the high-level methods above. ## Approving Delegations Use `approve` to grant another address permission to spend tokens on your behalf. This method works for both EVM and Solana chains. ### EVM Approval ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); try { // Approve a delegate to spend USDC on your behalf final response = await portal.delegations.approve( chain: 'eip155:11155111', // Sepolia testnet token: 'USDC', delegateAddress: '0xa944e86eb36f039becd1843132347eb5b8501562', amount: '0.01', ); // Sign and send the transaction final tx = response.transactions!.first!; final txResponse = await portal.request( chainId: 'eip155:11155111', method: 'eth_sendTransaction', params: [ {'from': tx.from, 'to': tx.to, 'data': tx.data, 'value': tx.value} ], ); print('Tx hash: ${txResponse.result}'); } catch (e) { print('Error approving EVM delegation: $e'); } ``` Each delegation response includes a `metadata` field with additional details such as `chainId`, `tokenSymbol`, `tokenAddress`, and `delegateAddress`. Access it via `response.metadata?.chainId` for approve/revoke, or `response.metadata.chainId` for transferFrom. ### Solana Approval ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); try { // Approve a delegate to spend USDC on your behalf final response = await portal.delegations.approve( chain: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', // Solana Devnet token: 'USDC', delegateAddress: '7smgSuU5mjP7QY5yWGdaTfgKn8hUWwvQgfvgcZB3HmJi', amount: '0.01', ); // Sign and send the transaction final encodedTx = response.encodedTransactions!.first!; final txResponse = await portal.request( chainId: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', method: 'sol_signAndSendTransaction', params: [encodedTx], ); print('Tx hash: ${txResponse.result}'); } catch (e) { print('Error approving Solana delegation: $e'); } ``` *** ## Checking Delegation Status Use `getStatus` to check current delegations and token balances for a specific delegate address. ### EVM Status Check ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); try { final response = await portal.delegations.getStatus( chain: 'eip155:11155111', token: 'USDC', delegateAddress: '0xa944e86eb36f039becd1843132347eb5b8501562', ); print('Chain ID: ${response.chainId}'); print('Token: ${response.token}'); print('Token Address: ${response.tokenAddress}'); if (response.balance != null) print('Balance: ${response.balance}'); if (response.balanceRaw != null) print('Balance Raw: ${response.balanceRaw}'); print('Delegations: ${response.delegations.length}'); for (final delegation in response.delegations) { print(' - Address: ${delegation!.address}, Amount: ${delegation.delegateAmount} (Raw: ${delegation.delegateAmountRaw})'); } } catch (e) { print('Error getting EVM delegation status: $e'); } ``` ### Solana Status Check ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); try { final response = await portal.delegations.getStatus( chain: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', token: 'USDC', delegateAddress: '7smgSuU5mjP7QY5yWGdaTfgKn8hUWwvQgfvgcZB3HmJi', ); print('Chain ID: ${response.chainId}'); print('Token: ${response.token}'); if (response.tokenAccount != null) print('Token Account: ${response.tokenAccount}'); print('Delegations: ${response.delegations.length}'); for (final delegation in response.delegations) { print(' - Address: ${delegation!.address}, Amount: ${delegation.delegateAmount} (Raw: ${delegation.delegateAmountRaw})'); } } catch (e) { print('Error getting Solana delegation status: $e'); } ``` *** ## Revoking Delegations Use `revoke` to remove spending permissions from a delegate address. ### EVM Revoke ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); try { final response = await portal.delegations.revoke( chain: 'eip155:11155111', token: 'USDC', delegateAddress: '0xa944e86eb36f039becd1843132347eb5b8501562', ); // Sign and send the transaction final tx = response.transactions!.first!; final txResponse = await portal.request( chainId: 'eip155:11155111', method: 'eth_sendTransaction', params: [ {'from': tx.from, 'to': tx.to, 'data': tx.data, 'value': tx.value} ], ); print('Tx hash: ${txResponse.result}'); } catch (e) { print('Error revoking EVM delegation: $e'); } ``` ### Solana Revoke ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); try { final response = await portal.delegations.revoke( chain: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', token: 'USDC', delegateAddress: '7smgSuU5mjP7QY5yWGdaTfgKn8hUWwvQgfvgcZB3HmJi', ); // Sign and send the transaction final encodedTx = response.encodedTransactions!.first!; final txResponse = await portal.request( chainId: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', method: 'sol_signAndSendTransaction', params: [encodedTx], ); print('Tx hash: ${txResponse.result}'); } catch (e) { print('Error revoking Solana delegation: $e'); } ``` Always revoke unused delegations after completing operations to minimize security risks. *** ## Transferring as a Delegate Use `transferFrom` to transfer tokens from another address that has delegated spending permission to you. ### EVM Transfer From ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); try { final response = await portal.delegations.transferFrom( chain: 'eip155:11155111', token: 'USDC', fromAddress: '0x099699ed181517d4ce0ba4487bea671d31bb1db5', // Token owner toAddress: '0xdFd8302f44727A6348F702fF7B594f127dE3A902', // Recipient amount: '0.01', ); // Sign and send the transaction final tx = response.transactions!.first!; final txResponse = await portal.request( chainId: 'eip155:11155111', method: 'eth_sendTransaction', params: [ {'from': tx.from, 'to': tx.to, 'data': tx.data, 'value': tx.value} ], ); print('Tx hash: ${txResponse.result}'); } catch (e) { print('Error transferring EVM delegation: $e'); } ``` ### Solana Transfer From ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); try { final response = await portal.delegations.transferFrom( chain: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', token: 'USDC', fromAddress: 'ARttPLesu9RiX6H111Pfdc9Y2DhGy1B8P8jyyrD8Cj5b', // Token owner toAddress: 'GPsPXxoQA51aTJJkNHtFDFYui5hN5UxcFPnheJEHa5Du', // Recipient amount: '0.01', ); // Sign and send the transaction final encodedTx = response.encodedTransactions!.first!; final txResponse = await portal.request( chainId: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', method: 'sol_signAndSendTransaction', params: [encodedTx], ); print('Tx hash: ${txResponse.result}'); } catch (e) { print('Error transferring Solana delegation: $e'); } ``` **Delegation Roles**: `fromAddress` is the token owner who approved the delegation. Your wallet (the delegate) signs the transaction to transfer tokens from the owner to the `toAddress` recipient. *** ## Supported Networks Delegations work on all Portal-supported EVM and Solana chains: * **EVM**: Ethereum, Polygon, Base, Arbitrum, Optimism, Monad, and all other EVM-compatible chains * **Solana**: Solana Mainnet and Devnet For a complete list, see [Blockchain Support](/resources/blockchain-support). *** ## Next Steps * Learn about [signing transactions](./sign-a-transaction) * Explore [sending tokens](./send-tokens) * Review [delegation concepts](/resources/delegations) * Check out [performing swaps](./perform-swaps) # Eject a wallet Source: https://docs.portalhq.io/sdks/flutter/guide/eject-a-wallet The eject feature allows a user to construct private keys that can be imported into another wallet manager, such as MetaMask. **Warning:** Providing the custodian backup share to the client device puts both MPC shares on a single device, removing the multi-party security benefits of MPC. This operation should only be done for users who want to move off of MPC and into a single private key. **Use `portal.ejectPrivateKeys()` at your own risk!** ## Ejecting Methods You can eject wallets using either Portal-Managed Backups or Self-Managed Backups depending on your implementation. ## Portal-managed backups To eject the private keys for your users' wallets, their two matching backup shares need to be combined. Since both of these shares are stored on the Portal backend encrypted at rest, we first need to verify that the upcoming request to eject the wallet is not an attack. In order to do this, we make this a two-step process: **Implementation Requirements** 1. Make a Custodian-authed `PATCH` request using your Custodian API Key (the same API key you use to create new users) to the `/custodians/me/clients/{clientId}/enable-eject` endpoint to allow for this client to perform an `eject` operation 2. Call the `portal.ejectPrivateKeys()` function from your client application with the appropriate backup method ### Step 1: Enable the Eject Operation This request **must** be made from your backend. The Custodian API Key is a server-side secret and must never be embedded in your Flutter app — doing so exposes the key to every user of the app and lets them eject any other wallet on your custodian. Make a custodian-authenticated `PATCH` request using your Custodian API Key to authorize the ejection. Send a JSON body with the `walletId` and an `ejectableUntil` timestamp at least one minute in the future: ```http theme={null} # Enable eject for the ETH wallet PATCH /custodians/me/clients/{clientId}/enable-eject Authorization: Bearer Content-Type: application/json { "walletId": "", "ejectableUntil": "2030-01-01T15:23:00.470Z" } # Enable eject for the Solana wallet (separate request) PATCH /custodians/me/clients/{clientId}/enable-eject Authorization: Bearer Content-Type: application/json { "walletId": "", "ejectableUntil": "2030-01-01T15:23:00.470Z" } ``` ```http theme={null} PATCH /custodians/me/clients/{clientId}/enable-eject Authorization: Bearer Content-Type: application/json { "walletId": "", "ejectableUntil": "2030-01-01T15:23:00.470Z" } ``` The `walletId` can be found in the `wallets` property of `portal.getClient()` (`clientInfo.wallets[*].id`). If you are planning on ejecting multiple wallets for a user, you'll need to make multiple separate requests to "unlock" all required wallets. ### Step 2: Eject the Wallet After preparation, call the `ejectPrivateKeys()` method with the appropriate backup method that was originally used to secure the wallet: ```dart theme={null} // For Password backup method final passwordKeys = await portal.ejectPrivateKeys( method: PortalBackupMethod.password, password: 'YOUR-PASSWORD', ); // For Passkey backup method final passkeyKeys = await portal.ejectPrivateKeys( method: PortalBackupMethod.passkey, ); // For iCloud backup method (iOS only) final icloudKeys = await portal.ejectPrivateKeys( method: PortalBackupMethod.iCloud, ); // For Google Drive backup method final gdriveKeys = await portal.ejectPrivateKeys( method: PortalBackupMethod.googleDrive, ); ``` ```dart theme={null} // For Password backup method final passwordKeys = await portal.ejectPrivateKeys( method: PortalBackupMethod.password, password: 'YOUR-PASSWORD', ); // For Passkey backup method final passkeyKeys = await portal.ejectPrivateKeys( method: PortalBackupMethod.passkey, ); // For iCloud backup method (iOS only) final icloudKeys = await portal.ejectPrivateKeys( method: PortalBackupMethod.iCloud, ); // For Google Drive backup method final gdriveKeys = await portal.ejectPrivateKeys( method: PortalBackupMethod.googleDrive, ); ``` Those functions will: * Retrieve and combine shares from Portal's server * Perform the cryptographic operations to construct the private keys * Return the private keys as a string that can be imported into other wallet managers ## Self-Managed Backup (from your server) When using Self-Managed Backups, you store the backup shares in your own infrastructure. To eject a wallet, you'll need to provide both backup shares to the `portal.ejectPrivateKeys()` function. * **Client Backup Share** - Encrypted backup share received from `portal.backupWallet()` (the `cipherText`). * **Custodian Backup Share** - Raw backup share received from the `POST /backup` webhook. **Implementation Requirements** 1. Retrieve the encrypted client backup share from your storage 2. Retrieve the raw custodian backup share(s) from your storage 3. Call the eject function with both shares Example Implementation: ```dart theme={null} // Fetch the encrypted client backup share from your API. final cipherText = await yourApi.fetchEncryptedClientBackupShare( userId: userId, backupMethod: 'password', ); // Fetch the user's custodian backup share for Ethereum (SECP256K1) final orgBackupShare = await yourApi.fetchCustodianBackupShare( userId: userId, curve: 'secp256k1', ); // Fetch the user's custodian backup share for Solana (ED25519) final orgSolanaBackupShare = await yourApi.fetchCustodianBackupShare( userId: userId, curve: 'ed25519', ); final privateKeys = await portal.ejectPrivateKeys( method: PortalBackupMethod.password, password: 'YOUR-PASSWORD', cipherText: cipherText, orgBackupShare: orgBackupShare, orgSolanaBackupShare: orgSolanaBackupShare, ); // ✅ ETH & Solana private keys are now available for export. // // ⚠️ Never log or print the values. Hand them off to your secure export // flow (e.g. show in a confirmation modal, encrypt for transfer to // another wallet manager). final evmKey = privateKeys['eip155']; final solanaKey = privateKeys['solana']; ``` ```dart theme={null} // Fetch the encrypted client backup share from your API. final cipherText = await yourApi.fetchEncryptedClientBackupShare( userId: userId, backupMethod: 'password', ); // Fetch the user's custodian backup share from your API. final orgBackupShare = await yourApi.fetchCustodianBackupShare( userId: userId, curve: 'secp256k1', ); final privateKeys = await portal.ejectPrivateKeys( method: PortalBackupMethod.password, password: 'YOUR-PASSWORD', cipherText: cipherText, orgBackupShare: orgBackupShare, ); // ✅ The ETH private key is now available for export. // // ⚠️ Never log or print the value. Hand it off to your secure export flow. final evmKey = privateKeys['eip155']; ``` ## Cryptographic Curves Portal supports multiple blockchain networks that use different cryptographic curves: * **SECP256K1**: Used by Ethereum and most EVM-compatible blockchains * **ED25519**: Used by Solana and several other blockchains You can learn more about signing algorithms and curves [here](http://ethanfast.com/top-crypto.html). When ejecting wallets, make sure to provide the appropriate backup shares for each curve your application supports. ## Security Considerations After ejecting private keys: 1. **Store them securely** - Never share or expose your private keys 2. **Consider the wallet compromised** - The MPC security model no longer applies 3. **Create a new wallet if needed** - For continued use with Portal, create a new MPC wallet ## Use Cases Private key ejection should only be used in specific scenarios: 1. **Migration** - Moving to a different wallet provider 2. **Advanced users** - Who need direct access for specific operations 3. **Compliance** - Regulatory requirements that mandate key access And that's it! Now your users can obtain a private key to import their wallet into another wallet manager. **Related Documentation** * [Backup options](../../../resources/backup-options/gdrive) * [ejectPrivateKeys function reference](../reference/ejectprivatekeys) * [getClient function reference](../reference/getclient) # Evaluate a transaction Source: https://docs.portalhq.io/sdks/flutter/guide/evaluate-a-transaction Learn how to evaluate transactions for security risks before sending them. Portal integrates with Blockaid to provide transaction security evaluation. This allows you to scan transactions for potential security risks before sending them, helping to identify malicious contracts, phishing attempts, and other threats. ## Transaction Evaluation The `evaluateTransaction` method analyzes a transaction and returns a risk assessment. ### Basic Evaluation ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Evaluate a transaction before sending final result = await portal.evaluateTransaction( chainId: 'eip155:1', to: '0xContractAddress', value: '0x0', data: '0xa9059cbb...', // Contract call data ); print('Result: ${result.result}'); print('Classification: ${result.classification}'); print('Reason: ${result.reason}'); print('Description: ${result.description}'); ``` ### Full Transaction Evaluation For complete transaction parameters: ```dart theme={null} final result = await portal.evaluateTransaction( chainId: 'eip155:1', to: '0xContractAddress', from: '0xYourAddress', value: '0x10', data: '0x', gas: '0x5208', maxFeePerGas: '0x3B9ACA00', maxPriorityFeePerGas: '0x3B9ACA00', operationType: PortalEvaluateTransactionOperationType.all, ); ``` ## Operation Types You can specify what type of evaluation to perform: | Operation Type | Description | | -------------- | ------------------------------------------------------ | | `validation` | Only validate the transaction structure and parameters | | `simulation` | Only simulate the transaction execution | | `all` | Perform both validation and simulation | ```dart theme={null} // Validation only final validationResult = await portal.evaluateTransaction( chainId: 'eip155:1', to: '0xContractAddress', operationType: PortalEvaluateTransactionOperationType.validation, ); // Simulation only final simulationResult = await portal.evaluateTransaction( chainId: 'eip155:1', to: '0xContractAddress', operationType: PortalEvaluateTransactionOperationType.simulation, ); ``` ## Evaluating Before Sending A common pattern is to evaluate a transaction before sending it: ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); Future sendWithEvaluation({ required String chainId, required String to, required String value, }) async { // First, evaluate the transaction final evaluation = await portal.evaluateTransaction( chainId: chainId, to: to, value: value, operationType: PortalEvaluateTransactionOperationType.all, ); // Check the risk assessment if (evaluation.result == 'Malicious') { print('Transaction blocked due to security risks'); print('Reason: ${evaluation.reason}'); print('Description: ${evaluation.description}'); return null; } if (evaluation.result == 'Warning') { print('Warning: ${evaluation.description}'); // Optionally prompt user to confirm } // Proceed with the transaction final txHash = await portal.sendTransaction( chainId: chainId, to: to, value: value, ); return txHash; } ``` Transaction evaluation is a security tool, not a guarantee. Always exercise caution when interacting with unknown contracts. ## Understanding Results The evaluation result includes: * **result**: The overall assessment (e.g., `Benign`, `Warning`, `Malicious`) * **reason**: The reason for the assessment * **classification**: The classification category of the risk * **description**: A human-readable description of the findings * **status**: The status of the evaluation ```dart theme={null} final result = await portal.evaluateTransaction( chainId: 'eip155:1', to: '0xContractAddress', ); // Check the result if (result.result == 'Benign') { print('Transaction appears safe'); } else if (result.result == 'Warning') { print('Proceed with caution'); print('Classification: ${result.classification}'); print('Description: ${result.description}'); } else if (result.result == 'Malicious') { print('Transaction blocked'); print('Reason: ${result.reason}'); } ``` **Related Documentation** * [evaluateTransaction function reference](../reference/evaluatetransaction) # Subscribe to Portal events Source: https://docs.portalhq.io/sdks/flutter/guide/events Listen to Portal SDK events on Flutter via the idiomatic Dart Stream API. The Portal Flutter SDK exposes events emitted by the underlying native SDK as a single Dart broadcast `Stream`. ## Overview Portal emits events for connection state, chain changes, signing requests, dApp sessions, and more. Use these events to: * Drive UI for `autoApprove: false` flows — show approval prompts when the SDK requests a signature. * React to chain switches initiated by a connected dApp. * Track WalletConnect / Portal Connect session lifecycle. * Build progress and audit logging across native MPC operations. The Flutter SDK delivers these events as a [broadcast `Stream`](https://api.flutter.dev/flutter/dart-async/Stream-class.html) on the Portal singleton: ```dart theme={null} // Getter on Portal: Stream get events; ``` ```dart theme={null} // Usage: portal.events.listen((e) => print('${e.name}: ${e.data}')); ``` Filter with `.where(...)`, listen with `.listen(...)`, await one-shot with `.firstWhere(...)`, and stop listening by cancelling the returned `StreamSubscription`. Listeners are kept across `Portal.initialize()` calls so apps can subscribe at startup. ## Subscribing to events ### Listen to all events ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final subscription = portal.events.listen((event) { print('${event.name}: ${event.data}'); }); // Later, when you no longer need the subscription: await subscription.cancel(); ``` ### Filter by event type Use the `PortalEvent` enum to filter the stream to a single event: ```dart theme={null} portal.events .where((e) => e.event == PortalEvent.chainChanged) .listen((e) { print('Chain changed: ${e.data}'); }); ``` ### One-shot (await a single event) ```dart theme={null} final connectEvent = await portal.events .firstWhere((e) => e.event == PortalEvent.connect); print('Connected with payload: ${connectEvent.data}'); ``` ### Multiple subscribers Because `portal.events` is a broadcast stream, you can attach as many subscribers as you need without one consuming the events for the others: ```dart theme={null} final logSubscription = portal.events.listen((e) => print('[event] $e')); final signingSubscription = portal.events .where((e) => e.event == PortalEvent.portalSigningRequested) .listen((e) => showApprovalUi(e.data)); ``` ## Event types `portal.events` delivers `PortalEventData` — a small wrapper that carries the raw event name, the matching `PortalEvent` enum value (when known), and the JSON-decoded payload from the native side. ```dart theme={null} class PortalEventData { const PortalEventData({ required this.name, required this.event, required this.data, }); /// Raw event name as emitted by native. final String name; /// Null if the SDK does not yet recognize the name. final PortalEvent? event; /// Map, List, String, num, bool, or null — decoded from the native payload. final dynamic data; } ``` The `data` field's shape depends on the event — most events carry a `Map` decoded from the native JSON payload. ### PortalEvent enum | `PortalEvent` | Raw name | Description | | ------------------------------- | -------------------------------- | ------------------------------------------------------------------ | | `connect` | `connect` | A WalletConnect / Portal Connect session connected. | | `disconnect` | `disconnect` | A connected session disconnected. | | `chainChanged` | `chainChanged` | The active chain changed. | | `portalConnectChainChanged` | `portalConnect_chainChanged` | A Portal Connect peer requested a chain switch. | | `connectError` | `portal_connectError` | A connection attempt errored. | | `portalSigningRequested` | `portal_signingRequested` | The SDK is requesting a signature (use with `autoApprove: false`). | | `portalSigningApproved` | `portal_signingApproved` | A signing request was approved. | | `portalSigningRejected` | `portal_signingRejected` | A signing request was rejected. | | `portalSignatureReceived` | `portal_signatureReceived` | A signature was produced and is now available. | | `portalConnectSigningRequested` | `portalConnect_signingRequested` | A Portal Connect peer requested a signature. | | `portalDappSessionRequested` | `portal_dappSessionRequested` | A dApp is requesting a session. | | `portalDappSessionApproved` | `portal_dappSessionApproved` | The host approved a dApp session. | | `portalDappSessionRejected` | `portal_dappSessionRejected` | The host rejected a dApp session. | If the native SDK ever dispatches an event the Flutter SDK does not yet recognize, `PortalEventData.event` will be `null` while `name` and `data` are still populated — listeners can still log or react to the raw name without breaking. **Platform availability.** On **Android**,the only events that are currently exposed are `chainChanged`, `portalSignatureReceived`, `portalSigningApproved`, `portalSigningRejected`, and `portalSigningRequested`. The other app-session events are emitted on **iOS** only. Avoid relying on these events for cross-platform behavior until Android support lands. ## Emitting events back to the native SDK For bidirectional flows — most importantly approving or rejecting a pending signing request when `autoApprove: false` — use `portal.emit(...)`: ```dart theme={null} Future emit(PortalEvent event, [dynamic data]); ``` The SDK fires `PortalEvent.portalSigningRequested` when user approval is needed; the host inspects the request, surfaces UI, and emits `portalSigningApproved` (or `portalSigningRejected`) to let the original signing call resolve. ```dart theme={null} portal.events .where((e) => e.event == PortalEvent.portalSigningRequested) .listen((e) async { final approved = await showApprovalUi(e.data); if (approved) { await portal.emit(PortalEvent.portalSigningApproved, e.data); } else { await portal.emit(PortalEvent.portalSigningRejected, e.data); } }); ``` `data` is JSON-encoded before being sent across the platform channel — pass primitives, `Map`s, or `List`s (anything `jsonEncode` accepts). ## End-to-end example ```dart theme={null} import 'dart:async'; import 'package:portal_flutter/portal_flutter.dart'; class WalletEventLogger { WalletEventLogger(this._portal); final Portal _portal; final List> _subs = []; void start() { _subs.add( _portal.events.listen((e) { print('[portal] ${e.name}: ${e.data}'); }), ); _subs.add( _portal.events .where((e) => e.event == PortalEvent.chainChanged) .listen((e) { print('Chain changed to: ${e.data}'); }), ); _subs.add( _portal.events .where((e) => e.event == PortalEvent.portalSigningRequested) .listen((e) async { final approved = await _showApproval(e.data); await _portal.emit( approved ? PortalEvent.portalSigningApproved : PortalEvent.portalSigningRejected, e.data, ); }), ); } Future stop() async { for (final sub in _subs) { await sub.cancel(); } _subs.clear(); } Future _showApproval(dynamic request) async { // Render an approval sheet, return true/false based on user choice. return true; } } ``` ## Related * [Sign a transaction guide](./sign-a-transaction) * [Evaluate a transaction guide](./evaluate-a-transaction) # Upgrading to EIP-7702 Source: https://docs.portalhq.io/sdks/flutter/guide/evm-account-type Learn how to check wallet account type and upgrade EOA wallets to EIP-7702 using Portal's Flutter SDK. Portal's Flutter SDK provides EVM Account Type management through the `portal.evmAccountType` API. This enables checking wallet account types and upgrading EOA wallets to EIP-7702 on supported EVM chains. ## Overview The EVM Account Type functionality allows you to: * **Check wallet status** to determine the current account type * **Upgrade to EIP-7702** to enable ejecting the Portal wallet's EOA private key ## Prerequisites Before using EVM Account Type operations, ensure you have: * A properly initialized Portal client * An active wallet on a supported network (see [Create a wallet](./create-a-wallet)) * Understanding of [EVM Account Type concepts](/resources/evmAccountType) * You fund the [Upgrade Sponsor address with native tokens for the target chain in the Portal Dashboard](https://app.portalhq.io/upgrade-sponsorship) ## Checking Wallet Status Use `getStatus` to check the current account type of your wallet. The response includes both the account status and metadata about the wallet addresses. ```dart theme={null} try { final chainId = 'eip155:10143'; // Monad Testnet final response = await portal.evmAccountType.getStatus(chainId: chainId); print('Status: ${response.data.status}'); print('EOA Address: ${response.metadata.eoaAddress}'); if (response.metadata.smartContractAddress != null) { print('Smart Contract Address: ${response.metadata.smartContractAddress}'); } if (response.data.status == 'EIP_155_EOA') { print('Can upgrade to EIP-7702'); } else if (response.data.status == 'EIP_7702_EOA') { print('Already upgraded'); } else { print('Cannot upgrade'); } } catch (e) { print('Failed to get account status: $e'); } ``` **Response (`EvmAccountTypeStatusResponse`):** * `data.status`: One of `EIP_155_EOA`, `SMART_CONTRACT`, or `EIP_7702_EOA` * `metadata.eoaAddress`: The EOA address associated with the wallet * `metadata.smartContractAddress`: Your smart contract wallet address (optional, may be `null` for EOA-only accounts) * `metadata.chainId`: The chain identifier *** ## Upgrading to EIP-7702 Use `upgradeTo7702` to upgrade an EOA wallet to EIP-7702. This method automatically handles the entire upgrade process, including gas subsidization, and returns the transaction hash directly. ```dart theme={null} try { final chainId = 'eip155:10143'; // Monad Testnet // Upgrade to EIP-7702 final txHash = await portal.evmAccountType.upgradeTo7702(chainId: chainId); print('Upgrade done! TX: $txHash'); // Verify final status final status = await portal.evmAccountType.getStatus(chainId: chainId); print('Final Status: ${status.data.status}'); if (status.data.status == 'EIP_7702_EOA') { print('Successfully upgraded to EIP-7702!'); } } on PortalException catch (e) { print('Upgrade error: ${e.message}'); } catch (e) { print('Upgrade failed: $e'); } ``` **What happens during the upgrade:** 1. **Chain validation**: Verifies the chain uses the `eip155` namespace 2. **Status check**: Confirms the wallet is currently an `EIP_155_EOA` 3. **Authorization building**: Creates the EIP-7702 authorization list 4. **Signature**: Signs the authorization hash with your wallet 5. **Transaction submission**: Submits the subsidized upgrade transaction (gas is paid by Portal) 6. **Transaction hash**: Returns the on-chain transaction hash **Response:** The method returns a `String` containing the transaction hash of the submitted upgrade transaction. **Error handling:** The method throws a `PortalException` with the following error codes: * `NOT_INITIALIZED`: Portal was not initialized before calling the method * `EVM_ACCOUNT_TYPE_UPGRADE_ERROR`: The upgrade failed (e.g., wallet is not an `EIP_155_EOA`, unsupported chain, or transaction submission failed) *** The upgrade process is irreversible per wallet. Once upgraded to EIP-7702, the wallet cannot be reverted to a standard EOA on that chain. *** ## Supported Networks EVM Account Type upgrades are currently available on: * **Monad Testnet** (`eip155:10143`) For a complete list of supported chains, reach out to the Portal team. *** ## Next Steps * Learn about [signing transactions](./sign-a-transaction) * Explore [Portal API methods](./portal-api-methods) * Review [EVM Account Type concepts](/resources/evmAccountType) * Check out [wallet lifecycle management](./manage-wallet-lifecycle-states) # Feature flags Source: https://docs.portalhq.io/sdks/flutter/guide/feature-flags Enable or disable specific SDK behaviors using feature flags when initializing Portal. ## Overview The `PortalFeatureFlags` class is used to configure feature flags when initializing the Portal SDK. Each flag corresponds to a specific feature or behavior that can be toggled on or off. All flags are optional and immutable after initialization — they cannot be changed at runtime. ## Enabling feature flags Pass `featureFlags` when calling `portal.initialize()`: ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); await portal.initialize( apiKey: 'CLIENT_API_KEY_OR_CLIENT_SESSION_TOKEN', featureFlags: PortalFeatureFlags( useEnclaveMpcApi: true, ), ); ``` ## Available flags ### `usePreGeneratedWallet` * **Type:** `bool?` * **Default:** `null` (disabled) * **Description:** When enabled, `portal.createWallet()` attempts to claim a pre-generated wallet share instead of running the standard interactive MPC generation. This can make wallet creation faster. You do not need to change how you call `createWallet`. **How it works** Normally, `createWallet` runs the interactive MPC key generation protocol in real time. With this flag enabled, the SDK first tries to claim a share from a pre-computed pool. If that fails with an HTTP 5xx response from the enclave, the SDK automatically falls back to the standard generation flow — the fallback is transparent, and `createWallet` resolves or rejects exactly as it would through the standard flow. Failures that aren't a 5xx (for example, a malformed request or a network failure) are not retried and propagate as usual. **Expected failure cases** The most common failure to plan for with this flag enabled is a client that **already has a wallet**. The enclave rejects the claim with a `400` — `Not eligible to claim a pre-generated wallet`, or `Wallet already exists` when it's caught while finalizing the claim. Because that isn't a 5xx, the SDK does *not* fall back to standard generation: `createWallet()` throws a `PortalException` carrying the enclave's message. This applies to any client that already holds a stored signing share — one that created a wallet earlier, recovered a wallet, or had a wallet created on another device — not just a `createWallet()` call that happens twice in the same session. Only call `createWallet()` for clients that don't already have a wallet, and keep a `try`/`catch` around it: ```dart theme={null} try { final addresses = await portal.createWallet(); print('My Portal EVM address: ${addresses.ethereum}'); } on PortalException catch (e) { print('Wallet creation failed: ${e.message}'); } ``` **When to enable** Enable `usePreGeneratedWallet` when you want faster wallet creation without changing your integration. The resulting wallet is identical to one created through the standard flow. See [Pre-Generated Wallets](../../../resources/pre-generated-wallets) for a deeper explanation. **Limitations** * This is a performance optimization only; it doesn't change the API surface, the resulting wallet, or how backup/recovery works. * Fallback to standard generation happens on an HTTP 5xx response from the enclave only. A 4xx rejection (such as a client that already has a wallet), an unauthorized API key, and network failures propagate normally — so these produce a failed wallet creation rather than a slow one. Keep your existing error handling around `createWallet()`. **Example** ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); await portal.initialize( apiKey: 'CLIENT_API_KEY_OR_CLIENT_SESSION_TOKEN', featureFlags: PortalFeatureFlags( usePreGeneratedWallet: true, ), ); ``` *** ### `useEnclaveMpcApi` * **Type:** `bool?` * **Default:** `null` (disabled) * **Description:** Enables the use of the **Enclave MPC API** for signing transactions. When enabled, MPC operations are executed server-side in a secure AWS Nitro Enclave, ensuring consistent and faster signing times. **How it works** Executing MPC operations on client devices can lead to inconsistent signing times due to variations in device CPU performance. By enabling the `useEnclaveMpcApi` flag, the client key share is transmitted to a **Trusted Execution Environment (TEE)** hosted in an AWS Nitro Enclave. This ensures: 1. **Encrypted memory**: All data processed in the enclave is encrypted and inaccessible to anyone, including Portal employees. 2. **Verified execution**: Users can cryptographically verify that their request was handled in a secure enclave using signed measurements. **Example** ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); await portal.initialize( apiKey: 'CLIENT_API_KEY_OR_CLIENT_SESSION_TOKEN', featureFlags: PortalFeatureFlags( useEnclaveMpcApi: true, ), ); ``` *** ### `usePresignatures` * **Type:** `bool?` * **Default:** `null` (disabled) * **Description:** Enables the automatic use of presignatures to improve signing latency for EVM transactions. **How it works** When `usePresignatures` is enabled, the SDK automatically generates and uses presignatures in the background. This reduces the time required for transactions to be signed, as the SDK pre-computes part of the MPC signing flow. Users do not need to take any additional actions — the SDK handles presignature generation and consumption automatically. Presignatures currently only support the `SECP256K1` curve (EVM, Bitcoin). ED25519 (Solana) signing is unaffected by this flag — support is coming soon. **Example** ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); await portal.initialize( apiKey: 'CLIENT_API_KEY_OR_CLIENT_SESSION_TOKEN', featureFlags: PortalFeatureFlags( usePresignatures: true, ), ); ``` *** ## Important notes * All feature flags are **set at initialization time** and cannot be changed at runtime. * Each flag is optional (`bool?`) — you only need to set the flags you want to enable. * Flags not specified will use their default behavior (disabled). # Getting Started Source: https://docs.portalhq.io/sdks/flutter/guide/getting-started Follow this guide to integrate Portal in your Flutter app. Portal provides Stablecoin Infrastructure for organizations and their users.\ \ To integrate Portal, an organization adds a **client library** to their mobile app and a few **server API endpoints**. ## Installation Add the `portal_flutter` package to your Flutter project. ### Setup Add the following to your `pubspec.yaml`: ```yaml theme={null} dependencies: portal_flutter: ^LATEST_VERSION ``` Then run: ```bash theme={null} flutter pub get ``` The Portal Flutter SDK is a federated plugin with separate Android and iOS implementations. ## Platform Configuration ### Android Add the following to your `android/app/build.gradle`: ```gradle theme={null} android { defaultConfig { minSdk = 26 } } ``` ### iOS #### Deployment target The iOS implementation requires a deployment target of iOS 15.0 or higher. Set the iOS Deployment Target to 15.0 or higher in Xcode (**Runner → General → Minimum Deployments**), or with `platform :ios, '15.0'` in `ios/Podfile` if you use CocoaPods. #### Swift Package Manager Swift Package Manager is on by default from Flutter 3.44 onward, and requires no further setup — the plugin declares its own native dependencies and Xcode resolves them when you build. On earlier Flutter versions SPM is opt-in, so those projects fall back to CocoaPods and need the CocoaPods setup below. If SPM is turned off, re-enable it. `flutter config` applies to every project for your user account: ```bash theme={null} flutter config --enable-swift-package-manager ``` A project can also opt out on its own in `pubspec.yaml`, and that takes precedence over the `flutter config` setting — remove this block if the command alone has no effect: ```yaml theme={null} flutter: config: enable-swift-package-manager: false ``` #### CocoaPods Only needed on Flutter versions before 3.44, or when Swift Package Manager has been disabled. In your app's `ios/Podfile`, match the platform to the deployment target you set above and declare `PortalSwift`: ```ruby theme={null} platform :ios, '15.0' pod 'PortalSwift', :git => 'https://github.com/portal-hq/PortalSwift.git', :tag => '7.3.0' ``` The tag must match the `PortalSwift` version the plugin depends on. Do not add the `pod 'PortalSwift'` line while Swift Package Manager is enabled. It is already resolved by the plugin's Swift package, so declaring it in the `Podfile` as well resolves it a second time, and the module and the `Mpc.xcframework` it vendors are both built and linked twice. ## Initializing Portal To initialize `Portal` in your application, call the `initialize` method with your Client API Key. The `rpcConfig` parameter is a map of [CAIP-2 Chain IDs](../../../resources/chain-id-formatting#caip-2-chain-id-format) to their respective RPC URLs. ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); await portal.initialize( apiKey: 'YOUR_CLIENT_API_KEY', rpcConfig: { 'eip155:1': 'https://api.portalhq.io/rpc/v1/eip155/1', 'eip155:10143': 'https://api.portalhq.io/rpc/v1/eip155/10143', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': 'https://api.portalhq.io/rpc/v1/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1': 'https://api.portalhq.io/rpc/v1/solana/EtWTRABZaYq6iMfeYKouRu166VU2xqa1', }, ); ``` ### Optional Configuration The `initialize` method accepts additional optional parameters: ```dart theme={null} await portal.initialize( apiKey: 'YOUR_CLIENT_API_KEY', rpcConfig: { /* ... */ }, autoApprove: true, // Auto-approve transactions without user confirmation apiHost: 'api.portalhq.dev', // Custom API host for staging mpcHost: 'mpc.portalhq.dev', // Custom MPC host for staging featureFlags: PortalFeatureFlags(usePreGeneratedWallet: true), // Faster wallet creation ); ``` For faster wallet generation, enable the [`usePreGeneratedWallet`](./feature-flags#usepregeneratedwallet) feature flag here—no other code changes are needed. Now that we have our Portal instance, the next step is to generate wallets for your user. Let's create them! If you are using [Client Session Tokens (CSTs)](../../../resources/authentication-and-api-keys), this hint is for you. When your user's CST expires, all Portal SDKs will throw an error on the next MPC Operation the user makes (e.g. creating a wallet, backing up a wallet, recovering a wallet, or signing). That error will include a code **`SESSION_EXPIRED`** in the SDK methods, which you can use as an indicator to refresh your CST. # Enhance your wallets' security with Hypernative Source: https://docs.portalhq.io/sdks/flutter/guide/hypernative Learn how to integrate real-time security scanning for transactions, addresses, tokens, NFTs, and URLs using Portal's Flutter SDK with Hypernative. Portal's Flutter SDK provides comprehensive security scanning capabilities through the `portal.security.hypernative` API. This integration allows you to detect malicious transactions, flagged addresses, compromised tokens, and suspicious URLs before users interact with them. ## Overview The Hypernative integration enables you to: * **Scan transactions** before signing or submission (EVM, EIP-712, Solana) * **Scan addresses** for known malicious actors or compromised contracts * **Validate tokens** to detect scams, honeypots, or security risks * **Check NFTs** for fraudulent collections or suspicious activity * **Verify URLs** to prevent phishing attacks ## Prerequisites Before using Hypernative security scanning, ensure you have: * A properly initialized Portal client * Hypernative integration enabled in your Portal Dashboard (see [Hypernative Integration](../../../integrations/Security/hypernative)) ## Scanning EVM Transactions Use `scanEvmTx` to analyze standard Ethereum transactions before signing or sending. This method scans EVM transactions for malicious contract interactions, suspicious token approvals, and other security risks. ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); Future scanEvmTransaction() async { final response = await portal.security.hypernative.scanEvmTx( chain: 'eip155:1', fromAddress: '0x7C01728004d3F2370C1BBC36a4Ad680fE6FE8729', toAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', input: '0x095ea7b300000000000000000000000066ba61be3bab35c0c00038f335850a390b086fe300000000000000000000000000000000000000000fffffffffffffffffffffff', value: '0', nonce: '2340', gas: '3000000', gasPrice: '3000000', ); print('Hypernative EVM scan response: $response'); } ``` *** ## Scanning EIP-712 Typed Messages Use `scanEip712Tx` to analyze typed structured data before signing. This method is critical for detecting malicious permit signatures, phishing attempts, and unauthorized token approvals. EIP-712 defines a standard for hashing and signing typed structured data, commonly used for gasless approvals and off-chain signatures. The Flutter SDK accepts the EIP-712 message as a JSON-encoded string. ```dart theme={null} import 'dart:convert'; import 'package:portal_flutter/portal_flutter.dart'; Future scanTypedMessage() async { final eip712Message = jsonEncode({ 'primaryType': 'Permit', 'types': { 'EIP712Domain': [ {'name': 'name', 'type': 'string'}, {'name': 'version', 'type': 'string'}, {'name': 'chainId', 'type': 'uint256'}, {'name': 'verifyingContract', 'type': 'address'}, ], 'Permit': [ {'name': 'owner', 'type': 'address'}, {'name': 'spender', 'type': 'address'}, {'name': 'value', 'type': 'uint256'}, {'name': 'nonce', 'type': 'uint256'}, {'name': 'deadline', 'type': 'uint256'}, ], }, 'domain': { 'chainId': 'eip155:1', 'name': 'MyToken', 'version': '1', 'verifyingContract': '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', }, 'message': { 'owner': '0x7b1363f33b86d16ef7c8d03d11f4394a37d95c36', 'spender': '0x67beb4dd770a9c2cbc7133ba428b9eecdcf09186', 'value': 3000, 'nonce': 0, 'deadline': 50000000000, }, }); final response = await portal.security.hypernative.scanEip712Tx( walletAddress: '0x7b1363f33b86d16ef7c8d03d11f4394a37d95c36', chainId: 'eip155:1', eip712MessageJson: eip712Message, ); print('Hypernative EIP-712 scan response: $response'); } ``` *** ## Scanning Solana Transactions Use `scanSolanaTx` to analyze Solana transactions before signing. This method detects malicious program invocations, suspicious token transfers, and other Solana-specific security risks. The Flutter SDK accepts the Solana transaction as a JSON-encoded string. ```dart theme={null} import 'dart:convert'; Future scanSolanaTransaction() async { final transactionJson = jsonEncode({ 'rawTransaction': 'AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQADCQkVR3SiiKbW0l4c3NBsEn6+zn1o0YsyypPwN0GUhg4K5HK0Tb5GckDLYW+MsovQASt5EZ3bSH3nluRJAE69H61w0BRUDTrpYQcXosUun6/z2BROkRoH/1bL7KLU9s4lCav6k3ZZgV6qeZFwu4pu89WoIGaqUxG4C93XwVmmDy81v8qBaCSP4/UZfdo3q1bud/W+ixymkH8IMe0laQZYrSx4Uhyxec67hYm1VqLV7JTSSYaC/fm7KvWtZOSRzEFT2gMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAT4tlY/P4mFG1wDJl0ektVggHiZf73lTlHBVJ3fK0nDoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANG5fPtlMEOI/eXV7aPDlpcdLUKm8L3VoW6k/oJlCNLaBQYABQLARQQABgAJAwYAAAAAAAAABzwACQoLCwgyMzQMNQ0ONjcPEDg5EgETFBUWOhEXGBkaGxwdHh8gISI7IyQlJicCKCkqKywtAwQuLzAxPBFVCg8JAQcHBgYBAAAAAwHwCgYBExUbBgICAAAPAwIAAAYBISMoEQQBGQAPAwIAAAYBLjA2DwMCAAAGAgIAAAAIBgYICAADAQkGCQUFBgACBQAEBwEAAAgCAAUMAgAAADwaAAAAAAAABgAFBGDMBQAEPPm21Wu6wrmHu23/ZFNIumpp+ADooZjd4JQgvjnBxkUJAgEDBqWqCgmmCAUIBwu1tp+gcP/+Ri3C1tRXUbPdgqo6rVsj/qnqC959wTdC/mRARysLz9HS09TW19jZ2tsC1QYsNrdxMcm5Nq5FXZrM0IXpEA+ApFa+pz/JvkLz0+2vnwuztLW2t7i5uru8vgAPvBv8VUeRwDy9yD1NHIH5Ji6ZA+zrmpHejKOz4MP8SwrKy8zNzs/S09TVAdY=', 'version': '0', }); final response = await portal.security.hypernative.scanSolanaTx( transactionJson: transactionJson, showFullFindings: true, ); print('Hypernative Solana scan response: $response'); } ``` *** ## Scanning Addresses Use `scanAddresses` to check multiple addresses for known security risks. This method identifies malicious contracts, compromised wallets, sanctioned addresses, and other flagged entities. ```dart theme={null} Future scanAddresses() async { final response = await portal.security.hypernative.scanAddresses( addresses: const [ '0x31c05d73f2333b5a176cfdbb7c5ef96ec7bb04ac', '0x2753a0d37a2ad09be3ccc0afcb650bea8ea57a8f', ], ); print('Hypernative address scan response: $response'); } ``` *** ## Scanning NFTs Use `scanNfts` to validate NFT collections before displaying or allowing interactions. This method detects fraudulent collections, compromised contracts, and suspicious NFT activity. ```dart theme={null} Future scanNfts() async { final response = await portal.security.hypernative.scanNfts( nfts: [ HypernativeScanNftItem( address: '0x5C1B9caA8492585182eD994633e76d744A876548', evmChainId: 'eip155:1', ), HypernativeScanNftItem( address: '0xC2e0cA5FE0b9AbE1B86f3cC0b865448908D20A16', evmChainId: 'eip155:1', ), ], ); print('Hypernative NFT scan response: $response'); } ``` *** ## Scanning Tokens Use `scanTokens` to validate ERC-20 tokens before allowing swaps, transfers, or approvals. This method detects honeypots, scam tokens, and compromised token contracts. ```dart theme={null} Future scanTokens() async { final response = await portal.security.hypernative.scanTokens( tokens: [ HypernativeScanTokenItem( address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', evmChainId: 'eip155:1', ), ], ); print('Hypernative token scan response: $response'); } ``` *** ## Scanning URLs Use `scanUrl` to detect phishing sites and malicious domains before users navigate to them. This method is critical for protecting users from social engineering attacks. ```dart theme={null} Future scanUrl() async { final response = await portal.security.hypernative.scanUrl( url: 'https://vote-ondo.app', ); print('Hypernative URL scan response: $response'); } ``` *** ## Next Steps * Learn about [signing transactions](./sign-a-transaction) * Explore [transaction evaluation](./evaluate-a-transaction) * Check out the [Hypernative Integration setup](../../../integrations/Security/hypernative) # Native Platform Channels (iOS) Source: https://docs.portalhq.io/sdks/flutter/guide/ios Advanced guide for integrating Portal into your Flutter app using native platform channels. Most users should use the [Portal Flutter SDK](./getting-started) directly. This guide is for advanced use cases requiring custom platform channel integration. In order to integrate **Portal** into your **Flutter** App using native platform channels, you will need to write custom platform-specific code. On the client side, [`MethodChannel`](https://api.flutter.dev/flutter/services/MethodChannel-class.html?_gl=1*4sycbc*_ga*MTE0MjAwMDc0MS4xNzI2MDk0ODMw*_ga_04YGWK0175*MTcyNjE2NTI2Ny4yLjEuMTcyNjE2NTQ3NS4wLjAuMA..) enables sending messages that correspond to method calls. On the platform side, `MethodChannel` on Android ([MethodChannelAndroid](https://api.flutter.dev/javadoc/io/flutter/plugin/common/MethodChannel.html?_gl=1*wlfjam*_ga*MTE0MjAwMDc0MS4xNzI2MDk0ODMw*_ga_04YGWK0175*MTcyNjIzODQ5NC4zLjAuMTcyNjIzODQ5NC4wLjAuMA..)) and `FlutterMethodChannel` on iOS ([MethodChanneliOS](https://api.flutter.dev/ios-embedder/interface_flutter_method_channel.html?_gl=1*15n5mqb*_ga*MTE0MjAwMDc0MS4xNzI2MDk0ODMw*_ga_04YGWK0175*MTcyNjIzODQ5NC4zLjEuMTcyNjIzODYxNC4wLjAuMA..)) enable receiving method calls and sending back a result. These classes allow you to develop a platform plugin with very little 'boilerplate' code. You can check the Flutter official documentation for [platform channels](https://docs.flutter.dev/platform-integration/platform-channels). You can check our [Portal Flutter](https://github.com/portal-hq/portal_flutter) example. ## Add Portal implementation for iOS: 1. Open the iOS host portion of your **Flutter** app in **Xcode**. 2. Add `PortalSwift` to the iOS project, you can follow this [setup](../../quickstart). 3. Open `AppDelegate.swift` located under **Runner** > **Runner** in the project navigation. Override the `application:didFinishLaunchingWithOptions:` function and create a `FlutterMethodChannel` The client and host sides of a channel are connected through a channel name passed in the channel constructor. All channel names used in a single app must be unique; prefix the channel name with a unique 'identifier', for example: `your.bundle.identifier/portal`. ```swift theme={null} @UIApplicationMain @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { let controller : FlutterViewController = window?.rootViewController as! FlutterViewController let portalChannel = FlutterMethodChannel(name: "your.bundle.identifier/portal", binaryMessenger: controller.binaryMessenger) setCallHandler(channel: portalChannel) GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } ``` Then define a callback to handle any method invocation that comes from the Flutter side and they Switch on those Methods then execute the proper Swift implementation for each one and reply back to Flutter using a regular callback of type `FlutterResult` which expected to be passed from Flutter side as a completion handler. #### Here is an example implementation: ```swift theme={null} private func setCallHandler( channel: FlutterMethodChannel ) { channel.setMethodCallHandler { [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) in Task { switch call.method { case "initializePortal": if let apiKey = call.arguments as? String { PortalWrapper.initializePortal(apiKey: apiKey, result: result) } else { result(FlutterError(code: "INVALID_ARGUMENT", message: "Expected a String for apiKey", details: nil)) } case "createWallet": PortalWrapper.createWallet(result: result) // TODO: - Handle all the messages here. default: result(FlutterMethodNotImplemented) } } } } ``` And here is an `PortalWrapper` implementation: ```swift theme={null} import Flutter import PortalSwift class PortalWrapper { static var portal: Portal? static func initializePortal(apiKey: String, result: @escaping FlutterResult) { // Initialize portal here using the portal iOS SDK Task { do { portal = try Portal( apiKey, withRpcConfig: [ "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" : "https://api.mainnet-beta.solana.com", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": "https://api.devnet.solana.com" ] ) result([ "success": true, "message": "Portal initialized" ]) } catch { result(FlutterError(code: "FAILED", message: "Error registering portal with exception: \(error.localizedDescription)", details: nil)) } } } static func createWallet(result: @escaping FlutterResult) { // Create wallet here using the portal iOS SDK guard let portal = portal else { result(FlutterError(code: "UNAVAILABLE", message: "Portal is not initialized", details: nil)) return } Task { do { let (ethereum, solana) = try await portal.createWallet() result([ "success": true, "addresses": [ "ethereum": ethereum, "solana": solana ] ]) } catch { result(FlutterError(code: "FAILED", message: "Error creating portal wallet with exception: \(error.localizedDescription)", details: nil)) } } } } ``` ### Calling platform-specific code using platform channels Now Portal iOS implementation is ready for Flutter, Let's create the Flutter platform client: We will use the same channel name we defined above to initialize `MethodChannel` ```dart theme={null} static const platform = MethodChannel('your.bundle.identifier/portal'); ``` Next, invoke a method on the method channel, specifying the concrete method to call using the `String` identifier `initializePortal`. The call might fail, so wrap the `invokeMethod` call in a try-catch statement. ```dart theme={null} Future initializePortal(String apiKey) async { String message; try { final result = await platform.invokeMethod('initializePortal', apiKey); message = 'Success with message ${result?['message']}'; } on PlatformException catch (e) { message = "Failed to initialize portal with error: '${e.message}'."; } } ``` Finally, your Flutter function `initializePortal` to initialize portal is ready to be called. You can call it from Flutter side passing the `apiKey to initialize Portal.` And that's it! You're now equipped to integrate Portal into your Flutter app. # Bridge & Swap with Li.Fi Source: https://docs.portalhq.io/sdks/flutter/guide/lifi Learn how to bridge and swap tokens across multiple chains using Portal's Flutter SDK with Li.Fi integration. Portal's Flutter SDK provides comprehensive cross-chain bridging and swapping capabilities through the `portal.trading.lifi` API. This guide covers getting quotes, finding routes, executing swaps and bridges, and tracking transaction status. ## Overview The Li.Fi functionality allows you to: * **Get quotes** for bridging or swapping tokens across chains * **Find routes** to discover the best paths for your cross-chain transfers * **Execute swaps and bridges** by signing and submitting transactions * **Track transaction status** for cross-chain transfers ## Prerequisites Before using Li.Fi operations, ensure you have: * A properly initialized Portal client * An active wallet with the required token(s) on the source network (see [Create a wallet](./create-a-wallet)) * Li.Fi integration enabled in your Portal Dashboard (see [Li.Fi Integration](../../../integrations/Trading/lifi)) ## Getting a Quote Use the `getQuote` method to get a quote for bridging or swapping tokens across chains. ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; import 'dart:convert'; try { final userAddress = await portal.getAddress('eip155:1'); final request = LifiQuoteRequest( fromChainId: 'eip155:8453', // Base Mainnet toChainId: 'eip155:42161', // Arbitrum fromTokenAddress: 'ETH', toTokenAddress: 'USDC', fromAddress: userAddress, fromAmount: '100000000000000', // 0.0001 ETH (in wei) ); final response = await portal.trading.lifi.getQuote(request); final route = response.route; if (route != null) { // Process quote response final steps = route.steps; for (final step in steps) { if (step?.estimate != null) { print('From amount: ${step!.estimate!.fromAmount}'); print('To amount: ${step.estimate!.toAmount}'); print('Execution duration: ${step.estimate!.executionDuration}s'); } // Sign and submit the transaction if transactionRequest is available if (step?.transactionRequest != null) { await executeTransaction(step!.transactionRequest!, request.fromChainId); } } } } on PortalException catch (e) { print('Error getting quote: ${e.message}'); } ``` The response includes a route with steps, each containing a `transactionRequest` JSON string with the transaction details you'll need to sign and submit. ## Finding Routes Use the `getRoutes` method to discover available routes for your cross-chain transfer. ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; try { final userAddress = await portal.getAddress('eip155:1'); final request = LifiRoutesRequest( fromChainId: 'eip155:8453', // Base Mainnet fromAmount: '100000000000000', // 0.0001 ETH (in wei) fromTokenAddress: 'ETH', toChainId: 'eip155:42161', // Arbitrum toTokenAddress: 'USDC', fromAddress: userAddress, ); final response = await portal.trading.lifi.getRoutes(request); final routes = response.routes; // Find recommended route final recommendedRoute = routes.firstWhere( (route) => route?.tags.contains('RECOMMENDED') ?? false, orElse: () => routes.isNotEmpty ? routes.first : null, ); if (recommendedRoute != null) { print('Selected route: ${recommendedRoute.id}'); print('Steps: ${recommendedRoute.steps.length}'); print('From: ${recommendedRoute.fromAmountUSD} USD'); print('To: ${recommendedRoute.toAmountUSD} USD'); // Process route steps await processRouteSteps(recommendedRoute.steps, request.fromChainId); } } on PortalException catch (e) { print('Error getting routes: ${e.message}'); } ``` The response includes an array of routes with estimates, fees, and gas costs. Routes may be tagged as `RECOMMENDED`, `CHEAPEST`, or `FASTEST`. ## Collecting Integrator Fees Pass `integrator` and `fee` to collect a fee on each swap or bridge. `integrator` must be the exact integrator ID that you registered in the [LI.FI Portal](https://portal.li.fi/). `fee` is a decimal, not a percentage, so `0.005` means 0.5%. Valid values are `0 <= fee < 1`. LI.FI adds its own platform fee on top of your `fee`, so the total fee in the quote is higher than the value you pass. On Flutter these live inside `options` for both `getQuote` and `getRoutes`: ```dart theme={null} final request = LifiQuoteRequest( fromChainId: 'eip155:8453', toChainId: 'eip155:42161', fromTokenAddress: 'ETH', toTokenAddress: 'USDC', fromAddress: userAddress, fromAmount: '100000000000000', options: LifiRoutesRequestOptions( integrator: 'YOUR_INTEGRATOR_ID', fee: 0.005, // 0.5% ), ); ``` The quote response shows the fee breakdown in `estimate.feeCosts[]`. Your share appears as `integratorFee` in the `feeSplit` object: ```json theme={null} { "estimate": { "feeCosts": [ { "name": "LIFI Fixed Fee", "amount": "750", "percentage": "0.0075", "feeSplit": { "lifiFee": "250", "integratorFee": "500", "recipients": [ { "name": "lifi", "type": "FIXED", "fee": "250" }, { "name": "YOUR_INTEGRATOR_ID", "type": "FIXED", "fee": "500" } ] } } ] } } ``` Set up your integrator ID and fee receiver wallets in the [LI.FI Portal](https://portal.li.fi/) before you pass `fee`. LI.FI pays collected fees to those wallets, and you withdraw them from the LI.FI Portal. If you omit `integrator`, Portal sends its own default value and your fee configuration does not apply. The `referrer` parameter is an optional tracking string only. It does not control where fees are paid. New fee wallet configuration can take up to roughly 30 minutes to activate. Until then, quotes that pass `fee` fail with LI.FI error 1011 (`not configured for collecting fees on chain`). ## Getting Route Step Details Use the `getRouteStep` method to get detailed transaction information for a specific route step, including an unsigned transaction that you can then sign and submit to an RPC provider (the `transactionRequest` field). ```dart theme={null} Future getStepTransactionDetails(LifiStep step) async { try { final request = LifiStepTransactionRequest(step: step); final response = await portal.trading.lifi.getRouteStep(request); return response; } on PortalException catch (e) { print('Error getting step details: ${e.message}'); return null; } } ``` The response includes a `transactionRequest` JSON string with the unsigned transaction that you can sign and submit. ## Executing Swaps and Bridges After getting a quote or route step details, extract the transaction details from the `transactionRequest` JSON string and sign the transaction. Parse the JSON and extract the `to`, `value`, and `data` fields to sign and submit the transaction. ### Signing and Submitting Transactions ```dart theme={null} import 'dart:convert'; Future executeTransaction(String transactionRequestJson, String chainId) async { try { // Parse the transactionRequest JSON string final txParams = jsonDecode(transactionRequestJson) as Map; final to = txParams['to'] as String; final data = txParams['data'] as String?; // Extract value (default to 0x0 if not present) var value = '0x0'; if (txParams['value'] is String) { value = txParams['value'] as String; } else if (txParams['value'] is int) { value = '0x${(txParams['value'] as int).toRadixString(16)}'; } // Sign and send final txHash = await portal.sendTransaction( chainId: chainId, to: to, data: data, value: value, ); print('Transaction submitted: $txHash'); // Wait for on-chain confirmation final confirmed = await waitForConfirmation(txHash, chainId); if (confirmed) { print('Transaction confirmed'); } return txHash; } on PortalException catch (e) { print('Error executing transaction: ${e.message}'); return null; } } ``` The `transactionRequest` from Li.Fi may include `gasPrice` and `gasLimit` fields. You can remove these if you want Portal to estimate the gas for you, or include them if you want to use Li.Fi's estimates. ### Processing Multiple Route Steps For routes with multiple steps, process them sequentially: ```dart theme={null} Future processRouteSteps(List steps, String fromChainId) async { for (var index = 0; index < steps.length; index++) { final step = steps[index]; if (step == null) continue; print('Processing step ${index + 1}/${steps.length}: ${step.tool}'); // 1. Get transaction details for this step final stepResponse = await getStepTransactionDetails(step); final transactionRequest = stepResponse?.transactionRequest; if (transactionRequest == null) { print('Failed to get transaction details for step ${index + 1}'); return false; } // 2. Sign and submit the transaction await executeTransaction(transactionRequest, fromChainId); print('Step ${index + 1} completed'); } return true; } ``` ### Waiting for Transaction Confirmation ```dart theme={null} Future waitForConfirmation( String txHash, String chainId, { int maxAttempts = 30, Duration delay = const Duration(seconds: 2), }) async { for (var i = 0; i < maxAttempts; i++) { await Future.delayed(delay); try { final response = await portal.request( chainId: chainId, method: 'eth_getTransactionReceipt', params: [txHash], ); if (response.result != null) { final receipt = jsonDecode(response.result!) as Map; final status = receipt['status'] as String?; if (status == '0x1') { return true; // Transaction succeeded } else if (status == '0x0') { return false; // Transaction reverted } } } catch (e) { continue; } } return false; // Timeout } ``` ## Tracking Transaction Status Use the `getStatus` method to track the status of your cross-chain transfer. ```dart theme={null} Future trackLiFiStatus(String txHash, String fromChain) async { try { final request = LifiStatusRequest( txHash: txHash, fromChain: fromChain, ); final response = await portal.trading.lifi.getStatus(request); print('Status: ${response.status}'); if (response.transactionId != null) { print('Transaction ID: ${response.transactionId}'); } if (response.lifiExplorerLink != null) { print('Explorer: ${response.lifiExplorerLink}'); } // Check if complete switch (response.status) { case LifiTransferStatus.done: print('Transfer completed successfully!'); break; case LifiTransferStatus.failed: print('Transfer failed'); break; default: print('Transfer in progress...'); } } on PortalException catch (e) { print('Error getting status: ${e.message}'); } } ``` ### Polling for Cross-Chain Completion For cross-chain transfers, poll the status endpoint until the transfer completes: ```dart theme={null} Future pollForCompletion( String txHash, String fromChain, { int maxAttempts = 300, Duration pollInterval = const Duration(seconds: 2), }) async { for (var attempt = 0; attempt < maxAttempts; attempt++) { try { final request = LifiStatusRequest( txHash: txHash, fromChain: fromChain, ); final response = await portal.trading.lifi.getStatus(request); print('Polling (${attempt + 1}/$maxAttempts): ${response.status}'); switch (response.status) { case LifiTransferStatus.done: return true; case LifiTransferStatus.failed: return false; default: break; // Continue polling } } catch (e) { // Continue polling on error } await Future.delayed(pollInterval); } return false; // Timeout } ``` ## Example Flow Here's a complete example of executing a cross-chain bridge: ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; import 'dart:convert'; try { // 1. Get user address final userAddress = await portal.getAddress('eip155:1'); // 2. Get a quote final quoteRequest = LifiQuoteRequest( fromChainId: 'eip155:8453', // Base Mainnet toChainId: 'eip155:42161', // Arbitrum fromTokenAddress: 'ETH', toTokenAddress: 'USDC', fromAddress: userAddress, fromAmount: '100000000000000', // 0.0001 ETH (in wei) ); final quoteResponse = await portal.trading.lifi.getQuote(quoteRequest); final route = quoteResponse.route; if (route == null || route.steps.isEmpty) { print('No quote available'); return; } // 3. Get the first step's transaction request final firstStep = route.steps.first; String? transactionRequestJson = firstStep?.transactionRequest; // If transactionRequest is not directly available, fetch step details if (transactionRequestJson == null && firstStep != null) { final stepRequest = LifiStepTransactionRequest(step: firstStep); final stepResponse = await portal.trading.lifi.getRouteStep(stepRequest); transactionRequestJson = stepResponse.transactionRequest; } if (transactionRequestJson == null) { print('No transaction request available'); return; } // 4. Parse and execute the transaction final txParams = jsonDecode(transactionRequestJson) as Map; final to = txParams['to'] as String; final data = txParams['data'] as String?; var value = '0x0'; if (txParams['value'] is String) { value = txParams['value'] as String; } else if (txParams['value'] is int) { value = '0x${(txParams['value'] as int).toRadixString(16)}'; } final txHash = await portal.sendTransaction( chainId: quoteRequest.fromChainId, to: to, data: data, value: value, ); print('Transaction submitted: $txHash'); // 5. Track status for cross-chain completion final completed = await pollForCompletion( txHash, quoteRequest.fromChainId, ); if (completed) { print('Bridge completed successfully!'); } else { print('Bridge failed or timed out'); } } on PortalException catch (e) { print('Error: ${e.message}'); } ``` ## Best Practices 1. **Compare quotes/routes** before signing and submitting the transaction(s) to find the best option for your use case 2. **Process steps sequentially** for multi-step routes, ensuring each step completes before starting the next 3. **Handle network errors gracefully** and provide user feedback 4. **Monitor transaction status** for cross-chain transfers, as they may take longer than single-chain transactions 5. **Validate user balances** before initiating swaps or bridges ## Supported Networks Portal's Li.Fi integration supports the following mainnet networks: * Monad (`eip155:143`) * Ethereum (`eip155:1`) * Optimism (`eip155:10`) * BSC (`eip155:56`) * Gnosis (`eip155:100`) * Unichain (`eip155:130`) * Polygon (`eip155:137`) * Sonic (`eip155:146`) * Mantle (`eip155:5000`) * Base (`eip155:8453`) * Arbitrum (`eip155:42161`) * Celo (`eip155:42220`) * Avalanche (`eip155:43114`) * Linea (`eip155:59144`) * Berachain (`eip155:80094`) * Katana (`eip155:747474`) * Solana (`solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`) * Bitcoin (`bip122:000000000019d6689c085ae165831e93-p2wpkh`) For the complete list of networks Li.Fi supports across its ecosystem, refer to the [Li.Fi documentation](https://docs.li.fi). If you need a chain that isn't listed above, contact Portal support. **Testnets are not supported.** ## Next Steps * Learn about [signing transactions](./sign-a-transaction) * Explore [sending tokens](./send-tokens) * Check out [performing swaps](./perform-swaps) # Perform swaps Source: https://docs.portalhq.io/sdks/flutter/guide/perform-swaps Learn how to swap tokens using the Portal SDK. Portal provides a built-in swap functionality that allows users to exchange tokens directly from their wallet. ## Token Swaps The `swap` method allows you to exchange one token for another on supported chains. ### Basic Swap ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Perform a swap on Base final result = await portal.swap( swapsApiKey: 'YOUR_SWAPS_API_KEY', chainId: 'eip155:8453', // Base mainnet sellToken: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', // Native ETH buyToken: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC on Base amount: '1000000000000000', // 0.001 ETH in wei ); if (result.success) { print('Swap successful!'); print('Transaction hash: ${result.transactionHash}'); } else { print('Swap failed'); } ``` You'll need a Swaps API key to use this feature. Contact the Portal team to obtain one. ### Swap Parameters | Parameter | Description | | ------------- | -------------------------------------------------------- | | `swapsApiKey` | Your API key for the swaps service | | `chainId` | The chain ID in CAIP-2 format | | `sellToken` | Token address to sell (use `0xEeee...` for native token) | | `buyToken` | Token address to buy | | `amount` | Amount to sell in the smallest unit (wei for ETH) | ### Example: Swap USDC to ETH ```dart theme={null} final result = await portal.swap( swapsApiKey: 'YOUR_SWAPS_API_KEY', chainId: 'eip155:8453', sellToken: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC buyToken: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', // Native ETH amount: '10000000', // 10 USDC (6 decimals) ); ``` ## Supported Chains Token swaps are available on the following chains: * Ethereum Mainnet (`eip155:1`) * Base (`eip155:8453`) * Monad Testnet (`eip155:10143`) * Arbitrum (`eip155:42161`) * Optimism (`eip155:10`) * Polygon (`eip155:137`) Always verify token addresses before performing swaps. Incorrect addresses may result in loss of funds. ## Error Handling ```dart theme={null} try { final result = await portal.swap( swapsApiKey: 'YOUR_SWAPS_API_KEY', chainId: 'eip155:8453', sellToken: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', buyToken: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', amount: '1000000000000000', ); if (result.success) { print('Swap completed: ${result.transactionHash}'); } } on PortalException catch (e) { print('Swap failed: ${e.message}'); print('Error code: ${e.code}'); } ``` **Related Documentation** * [swap function reference](../reference/swap) # Recover a wallet Source: https://docs.portalhq.io/sdks/flutter/guide/recover-a-wallet This guide will walk you through how to recover a Portal wallet from a backup. If a user loses their device or needs to access their wallet on a new device, they can recover it using their backup. ## Recovery Process The recovery process requires: 1. The backup method that was originally used 2. The authentication credentials (password, passkey, or cloud storage access) ## Recovery Methods ### Password Recovery If the wallet was backed up with a password: ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Recover wallet with password final addresses = await portal.recoverWallet( method: PortalBackupMethod.password, password: 'THE-USER-PASSWORD', ); print('Wallet recovered!'); print('EVM address: ${addresses.ethereum}'); print('Solana address: ${addresses.solana}'); ``` ### Passkey Recovery If the wallet was backed up with a passkey: ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Configure passkey storage first await portal.configurePasskeyStorage( relyingPartyId: 'portalhq.io', relyingPartyOrigins: ['https://portalhq.io'], ); // Recover wallet with passkey final addresses = await portal.recoverWallet( method: PortalBackupMethod.passkey, ); print('Wallet recovered!'); ``` ### Google Drive Recovery If the wallet was backed up to Google Drive: ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Configure Google Drive storage first await portal.configureGoogleStorage( clientId: 'your-google-client-id', ); // Recover wallet with Google Drive final addresses = await portal.recoverWallet( method: PortalBackupMethod.googleDrive, ); print('Wallet recovered!'); ``` ### iCloud Recovery (iOS only) If the wallet was backed up to iCloud: ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Configure iCloud storage first await portal.configureICloudStorage(); // Recover wallet with iCloud final addresses = await portal.recoverWallet( method: PortalBackupMethod.iCloud, ); print('Wallet recovered!'); ``` ### Firebase Auth Recovery If the wallet was backed up with Firebase Auth: ```dart theme={null} import 'package:firebase_auth/firebase_auth.dart'; import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Configure Firebase storage first await portal.configureFirebaseStorage( getToken: () async { final user = FirebaseAuth.instance.currentUser; if (user == null) return null; return await user.getIdToken(true); }, ); // Recover wallet with Firebase final addresses = await portal.recoverWallet( method: PortalBackupMethod.firebase, ); print('Wallet recovered!'); print('EVM address: ${addresses.ethereum}'); print('Solana address: ${addresses.solana}'); ``` ## Checking Recovery Status Before attempting recovery, you can check if a wallet is recoverable: ```dart theme={null} // Check if wallet can be recovered final isRecoverable = await portal.isWalletRecoverable(); if (isRecoverable) { // Get available recovery methods final methods = await portal.availableRecoveryMethods(); print('Available recovery methods: $methods'); } else { print('No recovery options available'); } ``` ## Check Password Recovery Availability If you want to specifically check if password recovery is available: ```dart theme={null} final canRecoverWithPassword = await portal.isPasswordRecoverAvailable(); if (canRecoverWithPassword) { print('Password recovery is available'); } ``` If a user forgets their password and has no other backup method configured, there is no way to recover the wallet. Always recommend users set up multiple backup methods. **Related Documentation** * [recoverWallet function reference](../reference/recoverwallet) * [availableRecoveryMethods function reference](../reference/availablerecoverymethods) * [isWalletRecoverable function reference](../reference/iswalletrecoverable) # Send tokens Source: https://docs.portalhq.io/sdks/flutter/guide/send-tokens Here's exactly how you can send tokens from your Portal wallet to another address. ## Fund your Wallet Now that you have a wallet, the next step is to get test tokens for it. You can fund your wallet using **`portal.receiveTestnetAsset`**. If you are looking for a greater variety of test tokens, we recommend exploring our [faucets page](../../../resources/testnet-faucets). The `chainId` will need to be a [CAIP-2](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md) compliant Chain ID. For more info on Chain ID formatting, see [this doc](../../../resources/chain-id-formatting). ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Monad Testnet final chainId = 'eip155:10143'; // Fund your Portal wallet final response = await portal.receiveTestnetAsset( chainId: chainId, amount: '0.01', // You will receive 0.01 MON token: 'NATIVE', // Use "NATIVE" for the chain's native token ); print('Transaction hash: ${response.transactionHash}'); ``` ## Sending Tokens from your Wallet Portal provides two ways to send transactions: 1. **`portal.sendAsset()`** - A simple method for sending tokens from your Portal wallet. 2. **`portal.sendTransaction()`** - Direct access to the underlying provider for custom transactions. (You can learn more about this method [here](./sign-a-transaction).) For most use cases, we recommend using **`portal.sendAsset()`** as shown in the examples below. ### Submitting an EVM Transaction ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Monad Testnet final chainId = 'eip155:10143'; // Send the tokens final txHash = await portal.sendAsset( chainId: chainId, to: '0xDestinationAddress', // The recipient address amount: '0.0001', // Sends 0.0001 MON token: 'NATIVE', // Use "NATIVE" for the chain's native token ); print('Transaction hash: $txHash'); ``` **Account Abstraction clients:** `txHash` is a **UserOperation hash**, not an on-chain transaction hash — it will **not** resolve on a block explorer such as Etherscan or Monadscan. The on-chain transaction hash is only assigned once the bundler includes the UserOperation on-chain. Look up the UserOperation hash on a UserOp explorer such as [JiffyScan](https://jiffyscan.xyz/) to find the resulting transaction hash. See [Account abstraction](../../../resources/account-abstraction). ### Sending ERC-20 Tokens To send ERC-20 tokens, specify the token contract address: ```dart theme={null} // Send USDC on Ethereum mainnet final txHash = await portal.sendAsset( chainId: 'eip155:1', to: '0xDestinationAddress', amount: '10', // 10 USDC token: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC contract address ); print('Transaction hash: $txHash'); ``` ### Submitting a Solana Transaction You will need **`SOL`** to submit a Solana transaction, which is not currently supported by **`portal.receiveTestnetAsset`**. You can find a faucet to get test **`SOL`** tokens [here](../../../resources/testnet-faucets). ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Solana Devnet final chainId = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1'; // Send the tokens final txHash = await portal.sendAsset( chainId: chainId, to: 'DestinationAddress', // The recipient address (Solana format) amount: '0.0001', // Sends 0.0001 SOL token: 'NATIVE', ); print('Transaction hash: $txHash'); ``` You just sent your first token from your Portal wallet! ## Advanced: Controlling Gas Sponsorship If your client is using [Account Abstraction](../../../resources/account-abstraction), you can control whether Portal sponsors the gas fees for each transaction using the `sponsorGas` parameter. ### Example: User Pays Gas ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Ethereum Sepolia final chainId = 'eip155:11155111'; // Send tokens with user paying gas final txHash = await portal.sendAsset( chainId: chainId, to: '0xDestinationAddress', amount: '0.0001', token: 'NATIVE', sponsorGas: false, // Portal client pays transaction fees ); print('Transaction hash: $txHash'); ``` By setting `sponsorGas: false`, the Portal client will pay for the transaction fees instead of having them sponsored. This is useful for testing or when you want users to pay for specific operations. Omitting `sponsorGas` or setting it to `true` produces the same behavior - both will sponsor gas if your environment is configured for AA on that chain. Only `sponsorGas: false` changes the default behavior to disable sponsorship. Learn more about gas sponsorship control in the [Account Abstraction guide](../../../resources/account-abstraction#controlling-gas-sponsorship-per-transaction). **Related Documentation** * [sendAsset function reference](../reference/sendasset) * [sendTransaction function reference](../reference/sendtransaction) * [receiveTestnetAsset function reference](../reference/receivetestnetasset) # Sign a transaction Source: https://docs.portalhq.io/sdks/flutter/guide/sign-a-transaction This example shows how the Portal Provider interacts with the blockchain. This example shows how the **Portal Provider** interacts with **the blockchain**. ## Signing Messages ### Personal Sign The `signMessage` method uses the `personal_sign` RPC method which prepends the standard Ethereum message prefix before signing. ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Sign a message on Ethereum mainnet final chainId = 'eip155:1'; final message = 'Hello, Portal!'; final signature = await portal.signMessage( chainId: chainId, message: message, ); print('Signature: $signature'); ``` Ensure you have set the gateway URL correctly with [Infura](https://www.infura.io/) or [Alchemy](https://www.alchemy.com/) when you initialize the portal class. ### Typed Data Signing (EIP-712) For signing structured data according to the EIP-712 standard: ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; import 'dart:convert'; final portal = Portal(); final chainId = 'eip155:1'; // EIP-712 typed data structure final typedData = jsonEncode({ 'types': { 'EIP712Domain': [ {'name': 'name', 'type': 'string'}, {'name': 'version', 'type': 'string'}, {'name': 'chainId', 'type': 'uint256'}, ], 'Message': [ {'name': 'content', 'type': 'string'}, ], }, 'primaryType': 'Message', 'domain': { 'name': 'Example App', 'version': '1', 'chainId': 1, }, 'message': { 'content': 'Hello, Portal!', }, }); final signature = await portal.signTypedData( chainId: chainId, typedData: typedData, ); print('EIP-712 Signature: $signature'); ``` ### Raw Sign For signing raw data without any prefix: ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Message must be hex-encoded // "test" in hex is "74657374" final hexMessage = '74657374'; final signature = await portal.rawSign( chainId: 'eip155:1', message: hexMessage, signatureApprovalMemo: 'Signing test message', // Optional ); print('Raw signature: $signature'); ``` Raw sign does not add any prefix to the message. Use this for signing raw data or when working with chains that don't use Ethereum's message prefix. ## Sending Transactions ### Using sendTransaction For full control over transaction parameters: ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Monad Testnet final chainId = 'eip155:10143'; // Send a transaction final txHash = await portal.sendTransaction( chainId: chainId, to: '0xRecipientAddress', value: '0x10', // Value in wei (hex) data: '0x', // Optional contract data ); print('Transaction hash: $txHash'); ``` ### EIP-1559 Transactions For EIP-1559 style transactions with priority fees: ```dart theme={null} final txHash = await portal.sendTransaction( chainId: 'eip155:1', to: '0xRecipientAddress', value: '0x10', maxFeePerGas: '0x3B9ACA00', // 1 Gwei maxPriorityFeePerGas: '0x3B9ACA00', ); ``` ## Generic Provider Requests For any JSON-RPC method, use the `request` method: ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Get the balance of an address final response = await portal.request( chainId: 'eip155:1', method: 'eth_getBalance', params: ['0xYourAddress', 'latest'], ); print('Balance: ${response.result}'); ``` ### Estimating Gas ```dart theme={null} final response = await portal.request( chainId: 'eip155:10143', method: 'eth_estimateGas', params: [{ 'from': '0xYourAddress', 'to': '0xRecipientAddress', 'value': '0x10', }], ); print('Estimated gas: ${response.result}'); ``` ## Solana Signing For Solana message signing, use the Solana chain ID: ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Solana Devnet final chainId = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1'; final signature = await portal.signMessage( chainId: chainId, message: 'Hello from Solana!', ); print('Solana signature: $signature'); ``` **Related Documentation** * [signMessage function reference](../reference/signmessage) * [rawSign function reference](../reference/rawsign) * [sendTransaction function reference](../reference/sendtransaction) * [request function reference](../reference/request) # Sign a user operation Source: https://docs.portalhq.io/sdks/flutter/guide/sign-a-user-operation Sign an ERC-4337 UserOperation using your MPC key without broadcasting it. The `eth_signUserOperation` method allows you to sign an [ERC-4337](https://eips.ethereum.org/EIPS/eip-4337) UserOperation using your client's MPC key without broadcasting it to the network. The signed UserOperation can then be submitted through a separate backend such as [Pimlico](https://www.pimlico.io/) or a custom bundler endpoint. This method requires your client to be created with [Account Abstraction](../../../resources/account-abstraction) enabled (`isAccountAbstracted: true`). Non-AA clients will receive a `METHOD_UNSUPPORTED` error. ## Prerequisites Before using `eth_signUserOperation`, ensure that: 1. Your organization has Account Abstraction enabled. See the [Account Abstraction guide](../../../resources/account-abstraction) for setup instructions. 2. Your client was created with `isAccountAbstracted: true` via the [Create a new client endpoint](../../../apis/custodian/reference#create-a-new-client). 3. Your client has a wallet created and backed up. ## Signing a UserOperation Use the `request` method with the `eth_signUserOperation` RPC method. The params array takes a single object with standard Ethereum transaction fields. ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Sign a UserOperation on Sepolia testnet final result = await portal.request( chainId: 'eip155:11155111', method: 'eth_signUserOperation', params: [ { 'to': '0xRecipientAddress', 'value': '0x0', 'data': '0x', } ], ); if (result.error != null) { print('Error: ${result.error}'); } else { print('Signed UserOp: ${result.result}'); // result.result contains a hex-encoded signed UserOperation JSON } ``` The `chainId` must be a [CAIP-2](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md) compliant Chain ID for a [supported Account Abstraction network](../../../resources/account-abstraction#supported-networks). ## Transaction Parameters The params object supports the following fields: | Parameter | Type | Required | Description | | ---------------------- | -------- | -------- | ------------------------------------------- | | `to` | `String` | Yes | The recipient or contract address | | `value` | `String` | No | Value in wei (hex-encoded) | | `data` | `String` | No | Calldata (hex-encoded) | | `gas` | `String` | No | Gas limit (hex-encoded) | | `maxFeePerGas` | `String` | No | Max fee per gas for EIP-1559 (hex-encoded) | | `maxPriorityFeePerGas` | `String` | No | Max priority fee for EIP-1559 (hex-encoded) | | `nonce` | `String` | No | Transaction nonce (hex-encoded) | ## Response The method returns a `PortalProviderResponse` where `result` is a hex-encoded JSON string containing the signed UserOperation. You can decode this result and submit it to a bundler of your choice. ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final result = await portal.request( chainId: 'eip155:11155111', method: 'eth_signUserOperation', params: [ { 'to': '0xRecipientAddress', 'value': '0xDE0B6B3A7640000', // 1 ETH in wei 'data': '0x', } ], ); if (result.error != null) { throw Exception('Failed to sign UserOp: ${result.error}'); } // The signed UserOperation is a hex-encoded JSON string final signedUserOp = result.result; print('Signed UserOperation: $signedUserOp'); // Decode the hex string and submit to your bundler // e.g., send signedUserOp to Pimlico or your custom bundler endpoint ``` ## EIP-1559 Gas Parameters You can specify EIP-1559 gas parameters for finer control over fees: ```dart theme={null} final result = await portal.request( chainId: 'eip155:11155111', method: 'eth_signUserOperation', params: [ { 'to': '0xRecipientAddress', 'value': '0x0', 'data': '0x', 'maxFeePerGas': '0x3B9ACA00', // 1 Gwei 'maxPriorityFeePerGas': '0x3B9ACA00', // 1 Gwei } ], ); ``` ## Error Handling | Error | Description | | -------------------- | ---------------------------------------------------- | | `METHOD_UNSUPPORTED` | The client does not have Account Abstraction enabled | | `NOT_INITIALIZED` | Portal was not initialized | | `RPC_ERROR` | The RPC call returned an error | ```dart theme={null} try { final result = await portal.request( chainId: 'eip155:11155111', method: 'eth_signUserOperation', params: [ { 'to': '0xRecipientAddress', 'value': '0x0', 'data': '0x', } ], ); if (result.error != null) { print('Provider error: ${result.error}'); return; } print('Signed UserOp: ${result.result}'); } on PortalException catch (e) { print('Portal error: ${e.message}'); } ``` **Related Documentation** * [request function reference](../reference/request) * [eth\_signUserOperation reference](../reference/eth_signuseroperation) * [Account Abstraction guide](../../../resources/account-abstraction) * [Sign a transaction guide](./sign-a-transaction) # Earn with Yield.xyz Source: https://docs.portalhq.io/sdks/flutter/guide/yield-xyz Learn how to discover, enter, manage, and exit yield opportunities. Portal's Flutter SDK provides comprehensive yield opportunities capabilities through the `portal.yieldIntegrations.yieldxyz` API. This guide covers discovering yield opportunities, entering positions, managing existing positions, and exiting yield opportunities. ## Overview The yield functionality allows users to: * **Discover** available yield opportunities across different protocols and networks * **Enter** yield positions by depositing tokens into yield opportunities * **Manage** existing positions (claim rewards, voting, etc.) * **Exit** yield positions to withdraw aggregated tokens and rewards * **Track** yield balances and historical yield actions ## Prerequisites Before using yield operations, ensure you have: * A properly initialized Portal client * An active wallet with the required token(s) on the target network (see [Create a wallet](./create-a-wallet)) * Yield.xyz integration enabled in your Portal Dashboard (see [Yield.xyz Integration](../../../resources/integrations/yield-xyz)) ## Discovering Yield Opportunities Use the `discover` method to find available yield opportunities. For complete API documentation, see the [Yield.xyz API reference](https://docs.yield.xyz/reference/yieldscontroller_getyields). ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final response = await portal.yieldIntegrations.yieldxyz.discover( request: YieldXyzGetYieldsRequest( offset: 0, limit: 10, network: 'eip155:11155111', // Sepolia network // ... other parameters ), ); final yieldOpportunities = response.items; // Process and display yield opportunities for (final opp in yieldOpportunities.whereType()) { print('${opp.name}: ${opp.rewardRate}% ${opp.rewardRateType.name}'); } ``` Popular, high-quality USDC yield options with no lockups or limits: * USDC Aave V3 Lending: `base-usdc-aave-v3-lending` * USDC Fluid Vault: `base-usdc-fusdc-0xf42f5795d9ac7e9d757db633d693cd548cfd9169-4626-vault` * USDC Spark Savings Vault: `ethereum-usdc-spusdc-0x28b3a8fb53b741a8fd78c0fb9a6b2393d896a43d-4626-vault` ## Entering Yield Positions To enter a yield position, first discover the specific yield, then use the `enter` method. For complete API documentation, see the [Yield.xyz enter yield reference](https://docs.yield.xyz/reference/actionscontroller_enteryield). For the example below, we will use the yield opportunity with the ID `"ethereum-sepolia-link-aave-v3-lending"`. Fund your Portal client with the required `LINK` token to enter the position. ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final userAddress = await portal.getAddress('eip155:11155111'); final enterResponse = await portal.yieldIntegrations.yieldxyz.enter( yieldId: 'ethereum-sepolia-link-aave-v3-lending', address: userAddress, // Optional - defaults to current wallet address amount: '1', // 1 LINK token ); final transactions = enterResponse.transactions; // Process transactions, this is described in the "Transaction Processing" section below await processTransactions(transactions, 'eip155:11155111'); ``` ## Checking Yield Balances Retrieve current yield positions and balances. For complete API documentation, see the [Yield.xyz get balances reference](https://docs.yield.xyz/reference/yieldscontroller_getaggregatebalances). ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final userAddress = await portal.getAddress('eip155:11155111'); final response = await portal.yieldIntegrations.yieldxyz.getBalances( queries: [ YieldXyzGetBalancesQuery( address: userAddress, network: 'eip155:11155111', // Sepolia testnet ), ], ); final yieldPositions = response.items; // Process and display yield positions information for (final item in yieldPositions.whereType()) { for (final balance in item.balances.whereType()) { print('${balance.amount} ${balance.token.symbol} (earning: ${balance.isEarning})'); } } ``` We recommend always specifying a `yieldId` on each balance query. When `yieldId` is provided, Yield.xyz can resolve balances directly, so you don't need to call the `track` endpoint after entering or exiting positions. ```dart theme={null} final response = await portal.yieldIntegrations.yieldxyz.getBalances( queries: [ YieldXyzGetBalancesQuery( address: userAddress, network: 'eip155:11155111', yieldId: 'ethereum-sepolia-link-aave-v3-lending', ), ], ); ``` ## Exiting Yield Positions Use the `exit` method to withdraw from yield positions. For complete API documentation, see the [Yield.xyz exit yield reference](https://docs.yield.xyz/reference/actionscontroller_exityield). ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final userAddress = await portal.getAddress('eip155:11155111'); final exitResponse = await portal.yieldIntegrations.yieldxyz.exit( yieldId: 'ethereum-sepolia-link-aave-v3-lending', address: userAddress, // Optional - defaults to current wallet address amount: '0.001', ); final transactions = exitResponse.transactions; // Process transactions, this is described in the "Transaction Processing" section below await processTransactions(transactions, 'eip155:11155111'); ``` ## Managing Yield Positions If your Portal client has entered into a yield balance, they may have a yield balance that has an available `pendingActions`. You can use the `manage` method to perform actions on existing yield positions. For example, if the balance has a `pendingAction` of `WITHDRAW` or `CLAIM_REWARDS`, you can use the `manage` method to withdraw or claim rewards from the yield balance. For complete API documentation, see the [Yield.xyz manage yield reference](https://docs.yield.xyz/reference/actionscontroller_manageyield). ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final userAddress = await portal.getAddress('eip155:11155111'); final manageResponse = await portal.yieldIntegrations.yieldxyz.manage( yieldId: 'ethereum-sepolia-link-aave-v3-lending', address: userAddress, action: 'WITHDRAW', // Replace with the balance's `pendingAction` item's `type` value. passthrough: 'eyJhZGRyZXNzZXMiOnsiYWRkcmVzcyI6ImNvc21vczF5ZXk...', // Replace with the balance's `pendingAction` item's `passthrough` value. ); final transactions = manageResponse.transactions; // Process transactions, this is described in the "Transaction Processing" section below await processTransactions(transactions, 'eip155:11155111'); ``` The `passthrough` parameter is required and must come from a balance's pending action. Call `getBalances()` first, find a `YieldBalanceItem` with non-empty `pendingActions`, and use the `passthrough` value from the relevant pending action. The SDK throws a `PortalException` with code `MISSING_PASSTHROUGH` if it is null or empty. ## Getting Historical Actions Retrieve the history of yield actions for an address. For complete API documentation, see the [Yield.xyz get actions reference](https://docs.yield.xyz/reference/actionscontroller_getactions). ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final userAddress = await portal.getAddress('eip155:11155111'); final response = await portal.yieldIntegrations.yieldxyz.getHistoricalActions( address: userAddress, ); final pastActions = response.items; // Process and display past yield actions for (final action in pastActions.whereType()) { print('${action.intent.name}: ${action.status.name} - ${action.amount} (${action.createdAt})'); } ``` ## Transaction Processing Yield operations can require multiple transactions. Process them sequentially, submit each, track it, and wait for on-chain confirmation (e.g. using `eth_getTransactionReceipt`) before proceeding to the next. For complete API documentation, see the [Yield.xyz submit transaction hash reference](https://docs.yield.xyz/reference/transactionscontroller_submittransactionhash) and [get transaction details reference](https://docs.yield.xyz/reference/transactionscontroller_gettransaction). For account abstraction enabled Portal clients, use `eth_getUserOperationReceipt` instead of `eth_getTransactionReceipt` to wait for confirmation, since signing returns a *user operation hash*, not a transaction hash. If you don't specify a `yieldId` on your balance queries, you'll need to call `track` after each transaction so Yield.xyz can attribute the position. Pass the **transaction hash** (extracted from `response.result.receipt.transactionHash` for AA clients), not the user operation hash. ```dart theme={null} import 'dart:convert'; import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); Future processTransactions( List transactions, String chainId, ) async { final sorted = transactions .whereType() .toList() ..sort((a, b) => a.stepIndex.compareTo(b.stepIndex)); for (final tx in sorted) { if (tx.unsignedTransaction != null && tx.status == YieldXyzActionTransactionStatus.created) { final success = await signAndSubmitAndConfirm(tx, chainId); if (!success) break; } } } Future signAndSubmitAndConfirm( YieldXyzActionTransaction transaction, String chainId, ) async { final unsignedTxJson = transaction.unsignedTransaction; if (unsignedTxJson == null) return false; // Parse the unsigned transaction JSON string final txParams = jsonDecode(unsignedTxJson) as Map; try { // Sign and send the transaction final txHash = await portal.sendTransaction( chainId: chainId, to: txParams['to'] as String, value: txParams['value'] as String? ?? '0x0', data: txParams['data'] as String?, // Portal handles gas estimation automatically ); // Track the transaction with the yield system await portal.yieldIntegrations.yieldxyz.track( transaction.id, txHash, ); // Wait for transaction confirmation return await waitForReceipt(txHash: txHash, chainId: chainId); } catch (e) { print('Error signing and submitting transaction: $e'); return false; } } Future waitForReceipt({ required String txHash, required String chainId, int maxAttempts = 30, Duration delay = const Duration(seconds: 2), }) async { for (var i = 0; i < maxAttempts; i++) { await Future.delayed(delay); try { final response = await portal.request( chainId: chainId, method: 'eth_getTransactionReceipt', params: [txHash], ); if (response.result != null) { final receipt = jsonDecode(response.result!) as Map?; if (receipt != null) { final status = receipt['status'] as String?; if (status == '0x1') return true; // Transaction succeeded if (status == '0x0') return false; // Transaction reverted } } } catch (e) { // Continue waiting if request fails continue; } } return false; // Timeout } ``` ## Best Practices 1. **Always check yield availability** before attempting to enter positions 2. **Process transactions sequentially** as yield operations often require multiple steps and are dependent on previous transactions being mined successfully 3. **Handle network errors gracefully** and provide user feedback 4. **Monitor transaction status** and provide progress updates to users 5. **Validate user balances** before initiating yield operations 6. **Check for pending actions in balances before calling the `manage` method** to avoid conflicting or duplicate operations 7. **Test on testnets first (e.g., Sepolia) before moving to mainnet** to validate flows and configurations in a safe environment ## Supported Networks The yield functionality supports various networks including: * Monad (`eip155:143`) * Monad Testnet (`eip155:10143`) * Arbitrum (`eip155:42161`) * Avalanche C (`eip155:43114`) * Base (`eip155:8453`) * Base Sepolia (`eip155:84532`) * Celo (`eip155:42220`) * Core (`eip155:1116`) * Ethereum (`eip155:1`) * Ethereum Sepolia (`eip155:11155111`) * Fantom (`eip155:250`) * Gnosis (`eip155:100`) * Harmony (`eip155:1666600000`) * Hyperevm (`eip155:999`) * Katana (`eip155:747474`) * Linea (`eip155:59144`) * Moonriver (`eip155:1285`) * Optimism (`eip155:10`) * Optimism Sepolia (`eip155:11155420`) * Plasma (`eip155:9745`) * Polygon (`eip155:137`) * Polygon Amoy (`eip155:80002`) * Sonic (`eip155:146`) * Unichain (`eip155:130`) * Viction (`eip155:88`) * zkSync (`eip155:324`) * Solana (`solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`) * Solana Devnet (`solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`) * Stellar (`stellar:pubnet`) * Stellar Testnet (`stellar:testnet`) * Tron (`tron:mainnet`) ## Next Steps * Learn about [signing transactions](./sign-a-transaction) * Explore [transaction simulation](./evaluate-a-transaction) * Learn how to [send tokens](./send-tokens) # Swap Tokens with 0x Source: https://docs.portalhq.io/sdks/flutter/guide/zerox Learn how to swap tokens using Portal's Flutter SDK with 0x integration. Portal's Flutter SDK provides token swapping functionality through the `portal.trading.zeroX` API. This integration allows you to retrieve swap quotes, inspect available liquidity sources, and execute token swaps using 0x. ## Overview Using the 0x integration, you can: * Fetch **indicative prices** for token swaps * Fetch **swap quotes** between supported tokens * Retrieve **available liquidity sources** * **Execute swaps** by signing and submitting transactions All swap execution is performed by submitting the transaction data returned by 0x using `portal.sendTransaction` (or the lower-level `portal.request`). ## Prerequisites Before using the 0x API, make sure you have: * A properly initialized Portal client * An active wallet with sufficient balance on the source network (see [Create a wallet](./create-a-wallet)) * 0x integration enabled in your Portal Dashboard (see [0x Integration](../../../integrations/Trading/zerox)) OR have a 0x API Key available *** ## Using a Custom 0x API Key (Optional) By default, Portal uses the 0x API Key configured in the Portal Dashboard to communicate with the 0x integration. If you have a 0x API key that you want to test with locally, you can optionally include it via the named `zeroXApiKey` parameter on any of the three methods. ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; try { final sources = await portal.trading.zeroX.getSources( 'eip155:1', zeroXApiKey: 'YOUR_0X_API_KEY', ); print('Available sources: ${sources.sources}'); } on PortalException catch (e) { print('Error: [${e.code}] ${e.message}'); } ``` If you've configured your 0x API key in the Portal Dashboard, you can omit the `zeroXApiKey` parameter: ```dart theme={null} final sources = await portal.trading.zeroX.getSources('eip155:1'); ``` *** ## Getting a Price (Indicative) Use `portal.trading.zeroX.getPrice` to retrieve an **indicative price** for a token swap without generating executable transaction data. This method is useful for displaying prices, estimating swap outcomes, or building preview experiences without committing to a quote. ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; try { final request = ZeroXPriceRequest( chainId: 'eip155:1', buyToken: 'USDC', sellToken: 'ETH', sellAmount: '100000000000000', // 0.0001 ETH ); final price = await portal.trading.zeroX.getPrice(request); print('Buy Amount: ${price.buyAmount ?? "-"}'); print('Sell Amount: ${price.sellAmount ?? "-"}'); print('Liquidity Available: ${price.liquidityAvailable ?? false}'); print('Estimated Gas: ${price.gas ?? "-"}'); } on PortalException catch (e) { print('Error: [${e.code}] ${e.message}'); } ``` *** ## Getting a Swap Quote Use `portal.trading.zeroX.getQuote` to fetch a swap quote from 0x with executable transaction data. ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; try { final request = ZeroXQuoteRequest( chainId: 'eip155:1', buyToken: 'USDC', sellToken: 'ETH', sellAmount: '100000000000000', // 0.0001 ETH ); final quote = await portal.trading.zeroX.getQuote(request); print('Buy Amount: ${quote.buyAmount ?? "-"}'); print('Min Buy Amount: ${quote.minBuyAmount ?? "-"}'); print('Total Network Fee: ${quote.totalNetworkFee ?? "-"}'); final tx = quote.transaction; if (tx != null) { print('Transaction → to: ${tx.to}, gas: ${tx.gas}, value: ${tx.value}'); } } on PortalException catch (e) { print('Error: [${e.code}] ${e.message}'); } ``` *** ## Getting Liquidity Sources You can query available liquidity sources supported by 0x using `portal.trading.zeroX.getSources`. ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; try { final response = await portal.trading.zeroX.getSources('eip155:1'); // `sources` is List — filter out any nulls before use. final sources = response.sources.whereType().toList(); print('Available sources (${sources.length}): $sources'); print('Request id (zid): ${response.zid ?? "-"}'); } on PortalException catch (e) { print('Error: [${e.code}] ${e.message}'); } ``` *** ## Checking Quote Issues When getting a quote, you may encounter issues related to allowances, balances, or simulation. Always check for these before executing a swap: ```dart theme={null} final quote = await portal.trading.zeroX.getQuote(request); final issues = quote.issues; if (issues != null) { final allowance = issues.allowance; if (allowance != null) { print('Allowance issue: actual=${allowance.actual}, ' 'spender=${allowance.spender}'); } final balance = issues.balance; if (balance != null) { print('Balance issue: token=${balance.token}, ' 'actual=${balance.actual}, expected=${balance.expected}'); } if (issues.simulationIncomplete == true) { print('Warning: Simulation incomplete'); } final invalidSources = issues.invalidSourcesPassed; if (invalidSources != null && invalidSources.isNotEmpty) { print('Invalid sources: $invalidSources'); } } ``` *** ## Executing the Swap Once you receive a quote containing transaction data, execute the swap by submitting the transaction through the Portal SDK. The transaction data returned by 0x may include gas parameters such as `gas` or `gasPrice`. These fields are optional — you can omit them and let Portal estimate gas automatically, or include them if you prefer to use 0x's suggested values. The simplest path uses `portal.sendTransaction`, which forwards the call as `eth_sendTransaction` and returns the transaction hash: ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; try { // Step 1: Get the quote final quote = await portal.trading.zeroX.getQuote( ZeroXQuoteRequest( chainId: 'eip155:1', buyToken: 'USDC', sellToken: 'ETH', sellAmount: '100000000000000', // 0.0001 ETH ), ); final tx = quote.transaction; if (tx == null) { print('No transaction in quote response'); return; } // Step 2: Submit the transaction final txHash = await portal.sendTransaction( chainId: 'eip155:1', to: tx.to, data: tx.data, value: tx.value, ); print('Transaction submitted: $txHash'); } on PortalException catch (e) { print('Swap failed: [${e.code}] ${e.message}'); } ``` If you need full control over the request envelope, you can use `portal.request` directly: ```dart theme={null} final result = await portal.request( chainId: 'eip155:1', method: 'eth_sendTransaction', params: [ { 'to': tx.to, 'from': tx.from, 'data': tx.data, 'value': tx.value, 'gas': tx.gas, // optional 'gasPrice': tx.gasPrice, // optional } ], ); print('Transaction submitted: ${result.result}'); ``` *** ## Error Handling All three methods throw a `PortalException` when the native SDK or the 0x service returns an error. Catch it the same way you would any other Portal call: ```dart theme={null} try { final sources = await portal.trading.zeroX.getSources('eip155:1'); print('Success: ${sources.sources.length} sources'); } on PortalException catch (e) { // e.code is one of: // ZEROX_GET_SOURCES_ERROR // ZEROX_GET_PRICE_ERROR // ZEROX_GET_QUOTE_ERROR // and the underlying native error message is preserved in e.message. print('0x error: [${e.code}] ${e.message}'); } catch (e) { print('Unexpected error: $e'); } ``` *** ## Complete Swap Flow Example Here's a complete example of executing a token swap end-to-end: ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; Future performZeroXSwap(Portal portal) async { const chainId = 'eip155:1'; try { // Step 1: Discover available sources for the chain (optional) final sourcesResponse = await portal.trading.zeroX.getSources(chainId); final sources = sourcesResponse.sources.whereType().toList(); print('Sources (${sources.length}): ${sources.take(5).join(", ")}...'); // Step 2: Get an executable quote final quote = await portal.trading.zeroX.getQuote( ZeroXQuoteRequest( chainId: chainId, buyToken: 'USDC', sellToken: 'ETH', sellAmount: '100000000000000', // 0.0001 ETH ), ); // Step 3: Inspect issues before submitting final balance = quote.issues?.balance; if (balance != null && balance.actual == '0') { print('Insufficient balance — aborting'); return; } // Step 4: Submit the transaction final tx = quote.transaction; if (tx == null) { print('No transaction in quote response'); return; } final txHash = await portal.sendTransaction( chainId: chainId, to: tx.to, data: tx.data, value: tx.value, ); print('Transaction submitted: $txHash'); } on PortalException catch (e) { print('Swap failed: [${e.code}] ${e.message}'); } } ``` *** ## Supported Networks The `portal.trading.zeroX` API supports a predefined set of EIP-155 networks. Requests using unsupported chains will fail. | Network | EIP-155 Chain ID | | ---------- | ---------------- | | Ethereum | `eip155:1` | | Optimism | `eip155:10` | | BSC | `eip155:56` | | Unichain | `eip155:130` | | Polygon | `eip155:137` | | Worldchain | `eip155:480` | | Mantle | `eip155:5000` | | Base | `eip155:8453` | | Monad | `eip155:143` | | Mode | `eip155:34443` | | Arbitrum | `eip155:42161` | | Avalanche | `eip155:43114` | | Ink | `eip155:57073` | | Linea | `eip155:59144` | | Berachain | `eip155:80094` | | Blast | `eip155:81457` | | Scroll | `eip155:534352` | 0x supports mainnet networks only — calls against testnets surface as `PortalException(ZEROX_GET_QUOTE_ERROR)` from the native SDK. *** ## Next Steps * Learn how to [bridge across chains with Li.Fi](./lifi) * Learn how to [sign Ethereum transactions](./sign-a-transaction) * Explore how to [send tokens](./send-tokens) # availableRecoveryMethods Source: https://docs.portalhq.io/sdks/flutter/reference/availablerecoverymethods Get available recovery methods for a wallet. ## Function Signature ```dart theme={null} Future> availableRecoveryMethods([String? chainId]) ``` ## Description Returns a list of backup methods that can be used to recover the wallet. ## Parameters | Parameter | Type | Required | Description | | --------- | -------- | -------- | ----------------------------------------------------- | | `chainId` | `String` | No | Optional chain ID to check. If null, checks globally. | ## Returns **`List`** - A list of method names (e.g., `["Password", "GoogleDrive"]`). ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final methods = await portal.availableRecoveryMethods(); print('Available recovery methods:'); for (final method in methods) { print(' - $method'); } // Use the first available method if (methods.isNotEmpty) { final methodName = methods.first; switch (methodName) { case 'Password': await portal.recoverWallet( method: PortalBackupMethod.password, password: 'user-password', ); break; case 'GoogleDrive': await portal.configureGoogleStorage(clientId: 'your-client-id'); await portal.recoverWallet(method: PortalBackupMethod.googleDrive); break; // ... other methods } } ``` ## Errors | Code | Description | | ----------------- | -------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | ## Related * [Recover a wallet guide](../guide/recover-a-wallet) * [recoverWallet](./recoverwallet) * [isWalletRecoverable](./iswalletrecoverable) # backupWallet Source: https://docs.portalhq.io/sdks/flutter/reference/backupwallet Create a backup of the user's wallet. ## Function Signature ```dart theme={null} Future backupWallet({ required PortalBackupMethod method, String? password, }) ``` ## Description Creates a backup of the user's wallet using the specified backup method. The backup allows the user to recover their wallet on a new device. After `backupWallet` returns, call `response.confirm()` to mark the backup as complete with Portal. If your custodian storage step fails (Self-Managed Backups), call `response.discard()` instead so the native storage callback is released. For Portal-Managed Backups, `response.confirm()` and `response.discard()` are safe no-ops — Portal has already finalized the backup before `backupWallet` returns. ## Parameters | Parameter | Type | Required | Description | | ---------- | -------------------- | -------- | ------------------------------------------------- | | `method` | `PortalBackupMethod` | Yes | The backup method to use | | `password` | `String` | No | Required when using `PortalBackupMethod.password` | ### PortalBackupMethod | Value | Description | | ------------- | ------------------------------------------- | | `password` | Backup using a user-provided password | | `googleDrive` | Backup to Google Drive | | `iCloud` | Backup to iCloud (iOS only) | | `passkey` | Backup using passkey/WebAuthn | | `firebase` | Backup using Firebase Auth + secure enclave | ## Returns **`PendingBackup`** - An object containing: | Property | Type | Description | | ------------ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `cipherText` | `String` | The encrypted client backup share. Persist this on your own infrastructure when using Self-Managed Backups. | | `shareIds` | `List` | List of share identifiers used internally for tracking. | | `isPending` | `bool` | `true` when the caller must invoke `confirm()` or `discard()` (Self-Managed Backups). `false` for Portal-Managed Backups (already finalized natively). | | `confirm()` | `Future` | Marks the backup complete with Portal. No-op when `isPending` is `false`. | | `discard()` | `Future` | Releases the native storage callback without firing it. No-op when `isPending` is `false`. | ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); const method = PortalBackupMethod.password; final response = await portal.backupWallet( method: method, password: 'user-password', ); try { // (Self-Managed Backups) Persist response.cipherText on your own backend. await yourApi.storeEncryptedClientBackupShare( userId: userId, cipherText: response.cipherText, ); } catch (_) { // Storage step failed — release the pending callback so the user can retry. await response.discard(); rethrow; } // Storage succeeded — mark the backup complete with Portal. // If response.confirm() throws, let it propagate so the caller can retry. await response.confirm(); ``` ## Errors | Code | Description | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `NOT_INITIALIZED` | Portal was not initialized | | `WALLET_NOT_FOUND` | No wallet exists to backup | | `BACKUP_FAILED` | The backup operation failed | | `NO_PENDING_BACKUP` | `confirm()` or `discard()` was called on a `PendingBackup` whose native callback was already consumed (e.g., a second `confirm()` after the first succeeded) | | `PASSWORD_REQUIRED` | Password method selected but no password provided | ## Related * [Back up a wallet guide](../guide/back-up-a-wallet) * [recoverWallet](./recoverwallet) * [getClient](./getclient) * [isWalletBackedUp](./iswalletbackedup) # buildEip155Transaction Source: https://docs.portalhq.io/sdks/flutter/reference/buildeip155transaction Builds an unsigned EIP-155 transaction for Ethereum-compatible chains, with gas, value, and data populated by the Portal backend. ## Function Signature ```dart theme={null} Future buildEip155Transaction({ required String chainId, required String to, required String token, required String amount, }) ``` ## Description Builds an unsigned EIP-155 transaction via the Portal backend. The returned payload is ready to be signed and submitted using `portal.request(method: 'eth_sendTransaction', ...)`. ## Parameters | Parameter | Type | Required | Description | | --------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------- | | `chainId` | `String` | Yes | The chain ID in CAIP-2 format. Must start with `eip155:` (e.g. `eip155:1` for mainnet, `eip155:11155111` for Sepolia). | | `to` | `String` | Yes | The recipient address (`0x…`). | | `token` | `String` | Yes | The asset symbol (e.g. `ETH`) for native transfers, or the ERC-20 token contract address. | | `amount` | `String` | Yes | The human-readable amount (e.g. `"0.001"`, `"100"`). The backend converts this to the token's smallest unit. | ## Returns **`PortalBuildEip155TransactionResponse`** — the unsigned transaction plus metadata. | Property | Type | Description | | ------------- | -------------------------------- | ------------------------------------------------------------------------------- | | `transaction` | `PortalEip155Transaction` | The unsigned transaction payload to pass to `eth_sendTransaction`. | | `metadata` | `PortalBuildTransactionMetadata` | Token info, formatted amounts, and the resolved sender / recipient addresses. | | `error` | `String?` | Optional backend error message. Inspect this before submitting the transaction. | ### PortalEip155Transaction | Property | Type | Description | | -------- | --------- | ------------------------------------------------------------------- | | `from` | `String` | The sender address (the Portal wallet's EVM address). | | `to` | `String` | The destination address. | | `data` | `String?` | Optional hex-encoded calldata (populated for ERC-20 transfers). | | `value` | `String?` | Optional hex-encoded value in wei (populated for native transfers). | ### PortalBuildTransactionMetadata | Property | Type | Description | | --------------- | --------- | --------------------------------------------------------- | | `amount` | `String` | Formatted (human-readable) amount. | | `fromAddress` | `String` | Sender address. | | `toAddress` | `String` | Recipient address. | | `tokenAddress` | `String?` | ERC-20 contract address (null for native transfers). | | `tokenDecimals` | `int` | Token decimals. | | `tokenSymbol` | `String?` | Token symbol when known. | | `rawAmount` | `String` | Amount in the token's smallest unit (wei for native ETH). | ## Examples ### Native ETH transfer ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final response = await portal.buildEip155Transaction( chainId: 'eip155:11155111', // Sepolia to: '0x1234567890123456789012345678901234567890', token: 'ETH', amount: '0.001', ); if (response.error != null) { throw Exception('Failed to build transaction: ${response.error}'); } final symbol = response.metadata.tokenSymbol ?? 'ETH'; print('From: ${response.transaction.from}'); print('To: ${response.transaction.to}'); print('Value (wei): ${response.transaction.value}'); print('Amount: ${response.metadata.amount} $symbol'); ``` ### ERC-20 token transfer ```dart theme={null} final response = await portal.buildEip155Transaction( chainId: 'eip155:1', to: '0x1234567890123456789012345678901234567890', token: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC amount: '100', ); final symbol = response.metadata.tokenSymbol ?? 'Unknown'; print('Token: $symbol'); print('Token contract: ${response.metadata.tokenAddress}'); print('Calldata: ${response.transaction.data}'); print('Raw amount: ${response.metadata.rawAmount}'); ``` ### Build, then sign and submit `buildEip155Transaction` only constructs the transaction — pass the result to `request` to actually sign and broadcast it: ```dart theme={null} final built = await portal.buildEip155Transaction( chainId: 'eip155:1', to: recipient, token: 'ETH', amount: '0.01', ); if (built.error != null) { throw Exception(built.error); } final txResponse = await portal.request( chainId: 'eip155:1', method: 'eth_sendTransaction', params: [ { 'from': built.transaction.from, 'to': built.transaction.to, if (built.transaction.value != null) 'value': built.transaction.value, if (built.transaction.data != null) 'data': built.transaction.data, }, ], ); // portal.request returns a PortalProviderResponse with String? result + String? error. if (txResponse.error != null) { throw Exception('eth_sendTransaction failed: ${txResponse.error}'); } print('Transaction hash: ${txResponse.result}'); ``` ## Errors Throws a `PortalException` on failure: | Code | Description | | ----------------- | ------------------------------------------------------------------------------------------------------- | | `NOT_INITIALIZED` | Portal was not initialized. | | `BUILD_FAILED` | The backend could not build the transaction (insufficient funds, unsupported token, invalid recipient). | ```dart theme={null} try { final response = await portal.buildEip155Transaction( chainId: 'eip155:1', to: recipient, token: 'ETH', amount: '0.01', ); } on PortalException catch (e) { print('Build failed: ${e.code} - ${e.message}'); } ``` Always also check `response.error` — the backend may signal a soft failure (e.g. a non-fatal warning about insufficient balance) without throwing. ## Implementation Notes * `amount` is the **human-readable** value. The Portal backend handles decimal conversion based on the token. Pass `"0.001"` for 0.001 ETH, not `"1000000000000000"`. * For native transfers, use the symbol `"ETH"` (or the chain's native symbol). For tokens, use the contract address. * `chainId` must start with `eip155:`. Use [`buildSolanaTransaction`](./buildsolanatransaction) for Solana. ## Related * [buildSolanaTransaction](./buildsolanatransaction) * [request](./request) * [sendTransaction](./sendtransaction) * [Sign a transaction guide](../guide/sign-a-transaction) # buildSolanaTransaction Source: https://docs.portalhq.io/sdks/flutter/reference/buildsolanatransaction Builds an unsigned Solana transaction for native SOL or SPL token transfers via the Portal backend. ## Function Signature ```dart theme={null} Future buildSolanaTransaction({ required String chainId, required String to, required String token, required String amount, }) ``` ## Description Builds an unsigned Solana transaction via the Portal backend. The returned `transaction` is a base64-encoded serialized Solana transaction ready to be signed and submitted using `portal.request(method: 'sol_signAndSendTransaction', ...)`. ## Parameters | Parameter | Type | Required | Description | | --------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------- | | `chainId` | `String` | Yes | The chain ID in CAIP-2 format. Must start with `solana:` (e.g. `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` for mainnet). | | `to` | `String` | Yes | The recipient's Solana address (Base58). | | `token` | `String` | Yes | The asset symbol (`SOL`) for native transfers, or the SPL token mint address. | | `amount` | `String` | Yes | The human-readable amount (e.g. `"1"`, `"0.5"`). The backend converts to the token's smallest unit (lamports for SOL). | ## Returns **`PortalBuildSolanaTransactionResponse`** — the serialized transaction plus metadata. | Property | Type | Description | | ------------- | -------------------------------- | ------------------------------------------------------------------------------------------ | | `transaction` | `String` | A base64-encoded serialized Solana transaction. Pass this to `sol_signAndSendTransaction`. | | `metadata` | `PortalBuildTransactionMetadata` | Token info, formatted amounts, and the resolved sender / recipient addresses. | | `error` | `String?` | Optional backend error message. Inspect this before submitting the transaction. | See [`buildEip155Transaction`](./buildeip155transaction#portalbuildtransactionmetadata) for the `PortalBuildTransactionMetadata` shape. ## Examples ### Native SOL transfer ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final response = await portal.buildSolanaTransaction( chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', to: 'GxvUWHWwMpep8e6tXvVdaRmwEzKcUjhLYLhpXhS6wQtY', token: 'SOL', amount: '0.5', ); if (response.error != null) { throw Exception('Failed to build transaction: ${response.error}'); } print('Amount: ${response.metadata.amount} SOL'); print('From: ${response.metadata.fromAddress}'); print('To: ${response.metadata.toAddress}'); print('Serialized transaction: ${response.transaction}'); ``` ### SPL token transfer ```dart theme={null} final response = await portal.buildSolanaTransaction( chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', to: 'GxvUWHWwMpep8e6tXvVdaRmwEzKcUjhLYLhpXhS6wQtY', token: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // USDC mint amount: '100', ); final symbol = response.metadata.tokenSymbol ?? 'Unknown'; print('Token: $symbol'); print('Mint: ${response.metadata.tokenAddress}'); print('Decimals: ${response.metadata.tokenDecimals}'); ``` ### Build, then sign and submit ```dart theme={null} final built = await portal.buildSolanaTransaction( chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', to: recipient, token: 'SOL', amount: '0.1', ); if (built.error != null) { throw Exception(built.error); } final signResponse = await portal.request( chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', method: 'sol_signAndSendTransaction', params: [built.transaction], // base64-encoded serialized transaction ); // portal.request returns a PortalProviderResponse with String? result + String? error. if (signResponse.error != null) { throw Exception('sol_signAndSendTransaction failed: ${signResponse.error}'); } print('Solana signature: ${signResponse.result}'); ``` ## Errors Throws a `PortalException` on failure: | Code | Description | | ----------------- | --------------------------------------------------------------------------------------------------------------------- | | `NOT_INITIALIZED` | Portal was not initialized. | | `BUILD_FAILED` | The backend could not build the transaction (insufficient SOL for rent / fees, unsupported token, invalid recipient). | ```dart theme={null} try { final response = await portal.buildSolanaTransaction( chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', to: recipient, token: 'SOL', amount: '0.1', ); } on PortalException catch (e) { print('Build failed: ${e.code} - ${e.message}'); } ``` Always also check `response.error` for soft failures from the backend. ## Implementation Notes * `amount` is the **human-readable** value. Pass `"1"` for 1 SOL, not `"1000000000"` lamports. * For native transfers, use `"SOL"`. For SPL tokens, use the mint address. * The Portal backend automatically creates the recipient's Associated Token Account (ATA) for SPL transfers when needed — the resulting `transaction` already includes the ATA-creation instruction. * `chainId` must start with `solana:`. Use [`buildEip155Transaction`](./buildeip155transaction) for EVM chains. ## Related * [buildEip155Transaction](./buildeip155transaction) * [request](./request) * [sendTransaction](./sendtransaction) * [Sign a transaction guide](../guide/sign-a-transaction) # configureFirebaseStorage Source: https://docs.portalhq.io/sdks/flutter/reference/configurefirebasestorage Configure Firebase Auth as a backup storage method for wallet backup and recovery. **Function Signature** ```dart theme={null} Future configureFirebaseStorage({ required Future Function() getToken, }) ``` **Parameters** * **`getToken`** *(required)*: An async callback function that returns a fresh Firebase ID token (`String?`). This is called before each request to the secure enclave. Return `null` if no user is signed in. **Returns** * `Future` — Completes when Firebase storage is configured. **Throws** * `PlatformException` — If there is an error configuring Firebase storage on the native platform. **Example Usage** ```dart theme={null} import 'package:firebase_auth/firebase_auth.dart'; import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); await portal.configureFirebaseStorage( getToken: () async { final user = FirebaseAuth.instance.currentUser; if (user == null) return null; return await user.getIdToken(true); }, ); ``` You must call `configureFirebaseStorage()` before using `PortalBackupMethod.firebase` with `backupWallet()` or `recoverWallet()`. The user must be signed in to Firebase before performing backup or recovery operations. # configureGoogleStorage Source: https://docs.portalhq.io/sdks/flutter/reference/configuregooglestorage Configure Google Drive backup storage. ## Function Signature ```dart theme={null} Future configureGoogleStorage({ required String clientId, PortalGDriveBackupOption backupOption = PortalGDriveBackupOption.customFolder, String? folderName, @Deprecated('Use folderName instead. Will be removed in the next minor.') String? applicationName, bool? signOutAfterUse, bool? useNewExperimentalGoogleAuthApi, }) ``` ## Description Configures Google Drive backup storage. Must be called before using `PortalBackupMethod.googleDrive` for backup or recovery. `backupOption` selects where the encrypted backup is written. ## Parameters | Parameter | Type | Required | Description | | --------------------------------- | -------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `clientId` | `String` | Yes | Your Google OAuth client ID (from Google Cloud Console). | | `backupOption` | `PortalGDriveBackupOption` | No | Where the encrypted backup is written. Defaults to `customFolder` for backward compatibility. **New integrations should pass `appDataFolder`.** | | `folderName` | `String?` | No | Folder name for `customFolder`. Defaults to `_PORTAL_MPC_DO_NOT_DELETE_`. Ignored for `appDataFolder` and `appDataFolderWithFallback`. | | `applicationName` | `String?` | No | **Deprecated** — aliases `folderName` for one release. Migrate to `folderName`. | | `signOutAfterUse` | `bool?` | No | **Android only.** Signs the user out of Google after each backup / recovery operation. iOS ignores this flag. | | `useNewExperimentalGoogleAuthApi` | `bool?` | No | **Android only.** Opt into the new Credential Manager-based authentication flow. When `false` (default) the legacy `GoogleSignIn` flow is used. iOS ignores this flag. | ### PortalGDriveBackupOption | Value | Description | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `appDataFolder` | Stores backups in the hidden, app-specific App Data Folder in Google Drive. This folder is not visible to the user. | | `appDataFolderWithFallback` | Attempts to store backups and recover using the App Data Folder. If recovery fails, it automatically falls back to a user-visible Google Drive folder. `folderName` is ignored. | | `customFolder` | Stores backups in a user-visible folder named `folderName` (defaults to `_PORTAL_MPC_DO_NOT_DELETE_`). | ## Returns **`void`** ## Important Notes * All three options are supported by the native iOS and Android SDK versions bundled with the current `portal_flutter` release. If you pin or override a lower native SDK version, `appDataFolder` and `appDataFolderWithFallback` may be rejected at runtime and backups previously stored in the App Data Folder will be lost — keep your `portal_flutter` dependency current to avoid this. * Choose the appropriate `backupOption` based on your application's requirements: * Use `appDataFolder` to keep wallet backups hidden from users (recommended). * Use `customFolder` if you want users to see the backup file in their Drive UI. * Use `appDataFolderWithFallback` only when migrating an existing custom-folder integration. ## Example Usage ```dart theme={null} // Example 1: Using App Data Folder (recommended) await portal.configureGoogleStorage( clientId: 'your-google-client-id', backupOption: PortalGDriveBackupOption.appDataFolder, ); // Example 2: Using visible folder with custom name await portal.configureGoogleStorage( clientId: 'your-google-client-id', backupOption: PortalGDriveBackupOption.customFolder, folderName: 'MyAppBackups', ); // Example 3: Using App Data Folder for backup and recover with fallback to GDrive folder if recover fails await portal.configureGoogleStorage( clientId: 'your-google-client-id', backupOption: PortalGDriveBackupOption.appDataFolderWithFallback, ); ``` ## Errors Throws a `PortalException` on failure: | Code | Description | | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `NOT_INITIALIZED` | Portal was not initialized. Call `initialize()` first. | | `CONFIGURE_GDRIVE_ERROR` | Native SDK failed to configure Google Drive (e.g. unable to resolve a presenting view on iOS, invalid OAuth client ID, missing Google Play services on Android). Inspect `e.message` for the diagnostic. | ## Related Documentation For more information about Google Drive integration, see: * [Configure GDrive storage](../../../resources/backup-options/gdrive) * [Google Drive Backup Method](../guide/back-up-a-wallet) # configureICloudStorage Source: https://docs.portalhq.io/sdks/flutter/reference/configureicloud Configure iCloud backup storage. ## Function Signature ```dart theme={null} Future configureICloudStorage() ``` ## Description Configures iCloud backup storage. This must be called before using iCloud for backup or recovery operations. This method is only available on iOS. It will have no effect on Android. ## Parameters This method takes no parameters. ## Returns **`void`** ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Configure iCloud storage await portal.configureICloudStorage(); // Now you can use iCloud backup final response = await portal.backupWallet( method: PortalBackupMethod.iCloud, ); ``` ## Setup Requirements 1. Enable iCloud capability in your Xcode project 2. Enable iCloud Keychain 3. Configure your App ID in the Apple Developer Portal See the [iCloud backup options guide](../../../resources/backup-options/icloud) for detailed setup instructions. ## Errors | Code | Description | | ---------------------- | ---------------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `CONFIGURATION_FAILED` | Failed to configure iCloud storage | ## Related * [Back up a wallet guide](../guide/back-up-a-wallet) * [backupWallet](./backupwallet) * [configureGoogleStorage](./configuregooglestorage) * [configurePasskeyStorage](./configurepasskeystorage) # configurePasskeyStorage Source: https://docs.portalhq.io/sdks/flutter/reference/configurepasskeystorage Configure Passkey backup storage. ## Function Signature ```dart theme={null} Future configurePasskeyStorage({ required String relyingPartyId, required List relyingPartyOrigins, String? webAuthnHost, }) ``` ## Description Configures Passkey (WebAuthn) backup storage. This must be called before using passkeys for backup or recovery operations. ## Parameters | Parameter | Type | Required | Description | | --------------------- | -------------- | -------- | ------------------------------------- | | `relyingPartyId` | `String` | Yes | The relying party identifier (domain) | | `relyingPartyOrigins` | `List` | Yes | List of allowed origins | | `webAuthnHost` | `String` | No | Optional WebAuthn host URL | ## Returns **`void`** ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Configure passkey with Portal's relying party await portal.configurePasskeyStorage( relyingPartyId: 'portalhq.io', relyingPartyOrigins: ['https://portalhq.io'], ); // Now you can use passkey backup final response = await portal.backupWallet( method: PortalBackupMethod.passkey, ); ``` ### Using Your Own Domain ```dart theme={null} await portal.configurePasskeyStorage( relyingPartyId: 'your-domain.com', relyingPartyOrigins: ['https://your-domain.com'], webAuthnHost: 'https://webauthn.your-domain.com', ); ``` ## Setup Requirements ### Using Portal's Relying Party (`portalhq.io`) 1. Add `portalhq.io` as a web credential domain in your app 2. Share your app bundle ID with the Portal team ### Using Your Own Domain 1. Set up associated domains in your app 2. Serve an AASA file from your domain with `webcredential` configured 3. Configure the relying party ID to match your domain See Apple's documentation: * [Configuring an associated domain](https://developer.apple.com/documentation/xcode/configuring-an-associated-domain) * [Supporting associated domains](https://developer.apple.com/documentation/xcode/supporting-associated-domains) ## Errors | Code | Description | | ---------------------- | ----------------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `CONFIGURATION_FAILED` | Failed to configure passkey storage | ## Related * [Back up a wallet guide](../guide/back-up-a-wallet) * [backupWallet](./backupwallet) * [configureGoogleStorage](./configuregooglestorage) # createWallet Source: https://docs.portalhq.io/sdks/flutter/reference/createwallet Create a new MPC wallet for the user. ## Function Signature ```dart theme={null} Future createWallet() ``` ## Description Generates a new MPC wallet and returns the addresses for Ethereum and Solana. The private keys are split across multiple parties and never exist in full on the device. ## Parameters This method takes no parameters. ## Returns **`PortalAddresses`** - An object containing: | Property | Type | Description | | ---------- | --------- | ------------------------ | | `ethereum` | `String?` | The Ethereum/EVM address | | `solana` | `String?` | The Solana address | ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final addresses = await portal.createWallet(); print('EVM address: ${addresses.ethereum}'); print('Solana address: ${addresses.solana}'); ``` ## Errors | Code | Description | | ----------------- | ----------------------------------------------------- | | `NOT_INITIALIZED` | Portal was not initialized before calling this method | | `WALLET_EXISTS` | A wallet already exists for this client | ## Related * [Create a wallet guide](../guide/create-a-wallet) * [getAddresses](./getaddresses) * [doesWalletExist](./doeswalletexist) # delegations.approve Source: https://docs.portalhq.io/sdks/flutter/reference/delegations-approve Approve a token delegation. ## Function Signature ```dart theme={null} Future approve({ required String chain, required String token, required String delegateAddress, required String amount, }) ``` ## Description Grant a delegate address permission to transfer tokens on your behalf. Works across EVM and Solana chains. ## Parameters | Parameter | Type | Required | Description | | ----------------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------- | | `chain` | `String` | Yes | CAIP-2 chain ID (e.g., `"eip155:11155111"` for Sepolia, `"solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"` for Solana) | | `token` | `String` | Yes | Token symbol or address (e.g., `"USDC"`) | | `delegateAddress` | `String` | Yes | The address to delegate to | | `amount` | `String` | Yes | The amount to delegate (e.g., `"100.0"`) | ## Returns **`DelegationApproveResponse`** - An object containing: | Property | Type | Description | | --------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------- | | `transactions` | `List?` | Array of constructed transactions (for EVM chains) | | `encodedTransactions` | `List?` | Array of encoded transaction strings (for Solana) | | `metadata` | `DelegationApproveMetadata?` | Approval metadata with chain ID, delegate amount, delegate address, token symbol, etc. | ## Example ### EVM ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final response = await portal.delegations.approve( chain: 'eip155:11155111', token: 'USDC', delegateAddress: '0x1234...', amount: '100.0', ); // Sign and send the transaction final tx = response.transactions!.first!; final txResponse = await portal.request( chainId: 'eip155:11155111', method: 'eth_sendTransaction', params: [ {'from': tx.from, 'to': tx.to, 'data': tx.data, 'value': tx.value} ], ); print('Tx hash: ${txResponse.result}'); ``` ### Solana ```dart theme={null} final response = await portal.delegations.approve( chain: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', token: 'USDC', delegateAddress: '7smgSuU5mjP7QY5yWGdaTfgKn8hUWwvQgfvgcZB3HmJi', amount: '100.0', ); // Sign and send the transaction final encodedTx = response.encodedTransactions!.first!; final txResponse = await portal.request( chainId: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', method: 'sol_signAndSendTransaction', params: [encodedTx], ); print('Tx hash: ${txResponse.result}'); ``` ## Errors | Code | Description | | ------------------- | ------------------------------ | | `NOT_INITIALIZED` | Portal was not initialized | | `DELEGATION_FAILED` | The delegation approval failed | ## Related * [Manage Token Delegations guide](../guide/delegations) * [delegations.approveAndSubmit](./delegations-approveandsubmit) — approve, sign, and broadcast in one call * [delegations.revoke](./delegations-revoke) * [delegations.getStatus](./delegations-getstatus) * [delegations.transferFrom](./delegations-transferfrom) # delegations.approveAndSubmit Source: https://docs.portalhq.io/sdks/flutter/reference/delegations-approveandsubmit Approve a token delegation, then sign and broadcast it in one call. ## Function Signature ```dart theme={null} Future> approveAndSubmit({ required String chain, required String token, required String delegateAddress, required String amount, void Function(DelegationSubmitProgress)? onProgress, }) ``` ## Description Builds the approval transaction(s), then signs and broadcasts each one in order and returns the resulting hashes. The wallet's signer is wired up for you, and EVM and Solana chains are routed to `eth_sendTransaction` and `sol_signAndSendTransaction` respectively. This method returns as soon as each transaction is accepted by the network. It **does not wait for on-chain confirmation** — a returned hash means the transaction was submitted, not that it succeeded. If your flow depends on the delegation being active, wait for the receipt yourself, or poll [`delegations.getStatus`](./delegations-getstatus) before proceeding. Use [`delegations.approve`](./delegations-approve) instead when you need to inspect, modify, or sign the transaction yourself. ## Parameters | Parameter | Type | Required | Description | | ----------------- | ------------------------------------------ | -------- | --------------------------------------------------------------------------------------------------------------- | | `chain` | `String` | Yes | CAIP-2 chain ID (e.g., `"eip155:11155111"` for Sepolia, `"solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"` for Solana) | | `token` | `String` | Yes | Token symbol or address (e.g., `"USDC"`) | | `delegateAddress` | `String` | Yes | The address to delegate to | | `amount` | `String` | Yes | The amount to delegate (e.g., `"100.0"`) | | `onProgress` | `void Function(DelegationSubmitProgress)?` | No | Called as each transaction is signed and submitted | ### DelegationSubmitProgress | Property | Type | Description | | -------- | ---------------------- | ---------------------------------------------------------------------- | | `step` | `DelegationSubmitStep` | `signing` or `submitted` | | `index` | `int` | The 0-based index of this transaction in the sequence | | `total` | `int` | The total number of transactions in the sequence | | `hash` | `String?` | `null` on `signing`, and the broadcast transaction hash on `submitted` | ## Returns **`List`** — One transaction hash per broadcast transaction, in submission order. Most delegation flows produce a single transaction, so the list usually has one element. ## Example ### EVM ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final hashes = await portal.delegations.approveAndSubmit( chain: 'eip155:11155111', token: 'USDC', delegateAddress: '0x1234...', amount: '100.0', onProgress: (progress) { print('${progress.step.value} ${progress.index + 1}/${progress.total}'); }, ); print('Tx hashes: $hashes'); ``` ### Solana ```dart theme={null} final hashes = await portal.delegations.approveAndSubmit( chain: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', token: 'USDC', delegateAddress: '7smgSuU5mjP7QY5yWGdaTfgKn8hUWwvQgfvgcZB3HmJi', amount: '100.0', ); print('Tx signatures: $hashes'); ``` ## Errors All failures throw a `PortalException`. | Code | Description | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `DELEGATION_APPROVE_AND_SUBMIT_ERROR` | Building, signing, or broadcasting failed. The `message` carries the reason — usually the API, or Native SDK's own error. | ## Related * [Manage Token Delegations guide](../guide/delegations) * [delegations.approve](./delegations-approve) * [delegations.revokeAndSubmit](./delegations-revokeandsubmit) * [delegations.transferAndSubmit](./delegations-transferandsubmit) # delegations.getStatus Source: https://docs.portalhq.io/sdks/flutter/reference/delegations-getstatus Get the status of a token delegation. ## Function Signature ```dart theme={null} Future getStatus({ required String chain, required String token, required String delegateAddress, }) ``` ## Description Query current delegation state, balances, and active delegations for a specific token and delegate address. ## Parameters | Parameter | Type | Required | Description | | ----------------- | -------- | -------- | ------------------------------------------------------- | | `chain` | `String` | Yes | CAIP-2 chain ID (e.g., `"eip155:11155111"` for Sepolia) | | `token` | `String` | Yes | Token symbol or address (e.g., `"USDC"`) | | `delegateAddress` | `String` | Yes | The delegate address to query status for | ## Returns **`DelegationStatusResponse`** - An object containing: | Property | Type | Description | | -------------- | ------------------------- | -------------------------------------------------------------------- | | `chainId` | `String` | Chain identifier | | `token` | `String` | Token symbol | | `tokenAddress` | `String` | Address of the token | | `tokenAccount` | `String?` | Token account (Solana) | | `balance` | `String?` | Current balance | | `balanceRaw` | `String?` | Raw balance value | | `delegations` | `List` | Array of delegation status objects with address and delegate amounts | ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final status = await portal.delegations.getStatus( chain: 'eip155:11155111', token: 'USDC', delegateAddress: '0x1234...', ); print('Delegations: ${status.delegations.length}'); ``` ## Errors | Code | Description | | ------------------- | ------------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `DELEGATION_FAILED` | Failed to get delegation status | ## Related * [Manage Token Delegations guide](../guide/delegations) * [delegations.approve](./delegations-approve) * [delegations.revoke](./delegations-revoke) * [delegations.transferFrom](./delegations-transferfrom) # delegations.revoke Source: https://docs.portalhq.io/sdks/flutter/reference/delegations-revoke Revoke a token delegation. ## Function Signature ```dart theme={null} Future revoke({ required String chain, required String token, required String delegateAddress, }) ``` ## Description Remove a delegate's permission to transfer tokens on your behalf. Works across EVM and Solana chains. ## Parameters | Parameter | Type | Required | Description | | ----------------- | -------- | -------- | ------------------------------------------------------- | | `chain` | `String` | Yes | CAIP-2 chain ID (e.g., `"eip155:11155111"` for Sepolia) | | `token` | `String` | Yes | Token symbol or address (e.g., `"USDC"`) | | `delegateAddress` | `String` | Yes | The address to revoke delegation from | ## Returns **`DelegationRevokeResponse`** - An object containing: | Property | Type | Description | | --------------------- | ------------------------------------------ | ---------------------------------------------------------------- | | `transactions` | `List?` | Array of constructed transactions (for EVM chains) | | `encodedTransactions` | `List?` | Array of encoded transaction strings (for Solana) | | `metadata` | `DelegationRevokeMetadata?` | Revocation metadata with chain ID, revoked address, token symbol | ## Example ### EVM ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final response = await portal.delegations.revoke( chain: 'eip155:11155111', token: 'USDC', delegateAddress: '0x1234...', ); // Sign and send the transaction final tx = response.transactions!.first!; final txResponse = await portal.request( chainId: 'eip155:11155111', method: 'eth_sendTransaction', params: [ {'from': tx.from, 'to': tx.to, 'data': tx.data, 'value': tx.value} ], ); print('Tx hash: ${txResponse.result}'); ``` ### Solana ```dart theme={null} final response = await portal.delegations.revoke( chain: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', token: 'USDC', delegateAddress: '7smgSuU5mjP7QY5yWGdaTfgKn8hUWwvQgfvgcZB3HmJi', ); // Sign and send the transaction final encodedTx = response.encodedTransactions!.first!; final txResponse = await portal.request( chainId: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', method: 'sol_signAndSendTransaction', params: [encodedTx], ); print('Tx hash: ${txResponse.result}'); ``` ## Errors | Code | Description | | ------------------- | -------------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `DELEGATION_FAILED` | The delegation revocation failed | ## Related * [Manage Token Delegations guide](../guide/delegations) * [delegations.revokeAndSubmit](./delegations-revokeandsubmit) — revoke, sign, and broadcast in one call * [delegations.approve](./delegations-approve) * [delegations.getStatus](./delegations-getstatus) * [delegations.transferFrom](./delegations-transferfrom) # delegations.revokeAndSubmit Source: https://docs.portalhq.io/sdks/flutter/reference/delegations-revokeandsubmit Revoke a token delegation, then sign and broadcast it in one call. ## Function Signature ```dart theme={null} Future> revokeAndSubmit({ required String chain, required String token, required String delegateAddress, void Function(DelegationSubmitProgress)? onProgress, }) ``` ## Description Builds the revocation transaction(s), then signs and broadcasts each one in order and returns the resulting hashes. The wallet's signer is wired up for you, and EVM and Solana chains are routed to `eth_sendTransaction` and `sol_signAndSendTransaction` respectively. This method returns as soon as each transaction is accepted by the network. It **does not wait for on-chain confirmation** — a returned hash means the transaction was submitted, not that it succeeded. Poll [`delegations.getStatus`](./delegations-getstatus) if you need to confirm the delegation is gone. Use [`delegations.revoke`](./delegations-revoke) instead when you need to inspect, modify, or sign the transaction yourself. ## Parameters | Parameter | Type | Required | Description | | ----------------- | ------------------------------------------ | -------- | --------------------------------------------------------------------------------------------------------------- | | `chain` | `String` | Yes | CAIP-2 chain ID (e.g., `"eip155:11155111"` for Sepolia, `"solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"` for Solana) | | `token` | `String` | Yes | Token symbol or address (e.g., `"USDC"`) | | `delegateAddress` | `String` | Yes | The address to revoke delegation from | | `onProgress` | `void Function(DelegationSubmitProgress)?` | No | Called as each transaction is signed and submitted | See [`delegations.approveAndSubmit`](./delegations-approveandsubmit#delegationsubmitprogress) for the `DelegationSubmitProgress` fields. ## Returns **`List`** — One transaction hash per broadcast transaction, in submission order. Most delegation flows produce a single transaction, so the list usually has one element. ## Example ### EVM ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final hashes = await portal.delegations.revokeAndSubmit( chain: 'eip155:11155111', token: 'USDC', delegateAddress: '0x1234...', ); print('Tx hashes: $hashes'); ``` ### Solana ```dart theme={null} final hashes = await portal.delegations.revokeAndSubmit( chain: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', token: 'USDC', delegateAddress: '7smgSuU5mjP7QY5yWGdaTfgKn8hUWwvQgfvgcZB3HmJi', ); print('Tx signatures: $hashes'); ``` ## Errors All failures throw a `PortalException`. | Code | Description | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `DELEGATION_REVOKE_AND_SUBMIT_ERROR` | Building, signing, or broadcasting failed. The `message` carries the reason — usually the API, or Native SDK's own error. | ## Related * [Manage Token Delegations guide](../guide/delegations) * [delegations.revoke](./delegations-revoke) * [delegations.approveAndSubmit](./delegations-approveandsubmit) * [delegations.transferAndSubmit](./delegations-transferandsubmit) # delegations.transferAndSubmit Source: https://docs.portalhq.io/sdks/flutter/reference/delegations-transferandsubmit Transfer tokens as a delegate, then sign and broadcast it in one call. ## Function Signature ```dart theme={null} Future> transferAndSubmit({ required String chain, required String token, required String fromAddress, required String toAddress, required String amount, void Function(DelegationSubmitProgress)? onProgress, }) ``` ## Description Builds the delegated transfer transaction(s), then signs and broadcasts each one in order and returns the resulting hashes. The wallet's signer is wired up for you, and EVM and Solana chains are routed to `eth_sendTransaction` and `sol_signAndSendTransaction` respectively. The calling wallet must already be an approved delegate of `fromAddress`, and `amount` must not exceed the approved allowance. This method returns as soon as each transaction is accepted by the network. It **does not wait for on-chain confirmation** — a returned hash means the transaction was submitted, not that it succeeded. A delegated transfer can still revert (for example if the allowance was spent or revoked in the meantime). Use [`delegations.transferFrom`](./delegations-transferfrom) instead when you need to inspect, modify, or sign the transaction yourself. ## Parameters | Parameter | Type | Required | Description | | ------------- | ------------------------------------------ | -------- | --------------------------------------------------------------------------------------------------------------- | | `chain` | `String` | Yes | CAIP-2 chain ID (e.g., `"eip155:11155111"` for Sepolia, `"solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"` for Solana) | | `token` | `String` | Yes | Token symbol or address (e.g., `"USDC"`) | | `fromAddress` | `String` | Yes | The owner's address (the wallet that approved the delegation) | | `toAddress` | `String` | Yes | The recipient's address | | `amount` | `String` | Yes | The amount to transfer (e.g., `"50.0"`) | | `onProgress` | `void Function(DelegationSubmitProgress)?` | No | Called as each transaction is signed and submitted | See [`delegations.approveAndSubmit`](./delegations-approveandsubmit#delegationsubmitprogress) for the `DelegationSubmitProgress` fields. ## Returns **`List`** — One transaction hash per broadcast transaction, in submission order. Most delegation flows produce a single transaction, so the list usually has one element. ## Example ### EVM ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final hashes = await portal.delegations.transferAndSubmit( chain: 'eip155:11155111', token: 'USDC', fromAddress: '0xowner...', toAddress: '0xrecipient...', amount: '50.0', onProgress: (progress) { print('${progress.step.value} ${progress.index + 1}/${progress.total}'); }, ); print('Tx hashes: $hashes'); ``` ### Solana ```dart theme={null} final hashes = await portal.delegations.transferAndSubmit( chain: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', token: 'USDC', fromAddress: 'OWNER_SOLANA_ADDRESS', toAddress: 'RECIPIENT_SOLANA_ADDRESS', amount: '50.0', ); print('Tx signatures: $hashes'); ``` ## Errors All failures throw a `PortalException`. | Code | Description | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `DELEGATION_TRANSFER_AND_SUBMIT_ERROR` | Building, signing, or broadcasting failed. The `message` carries the reason — usually the API, or Native SDK's own error. | ## Related * [Manage Token Delegations guide](../guide/delegations) * [delegations.transferFrom](./delegations-transferfrom) * [delegations.approveAndSubmit](./delegations-approveandsubmit) * [delegations.revokeAndSubmit](./delegations-revokeandsubmit) # delegations.transferFrom Source: https://docs.portalhq.io/sdks/flutter/reference/delegations-transferfrom Transfer tokens from a delegated allowance. ## Function Signature ```dart theme={null} Future transferFrom({ required String chain, required String token, required String fromAddress, required String toAddress, required String amount, }) ``` ## Description Execute a transfer using previously approved delegated authority. The caller must have been granted delegation approval by the `fromAddress` owner. ## Parameters | Parameter | Type | Required | Description | | ------------- | -------- | -------- | ------------------------------------------------------- | | `chain` | `String` | Yes | CAIP-2 chain ID (e.g., `"eip155:11155111"` for Sepolia) | | `token` | `String` | Yes | Token symbol or address (e.g., `"USDC"`) | | `fromAddress` | `String` | Yes | The owner's address (who approved the delegation) | | `toAddress` | `String` | Yes | The recipient's address | | `amount` | `String` | Yes | The amount to transfer (e.g., `"50.0"`) | ## Returns **`DelegationTransferFromResponse`** - An object containing: | Property | Type | Description | | --------------------- | ------------------------------------------ | --------------------------------------------------------------- | | `transactions` | `List?` | Array of constructed transactions (for EVM chains) | | `encodedTransactions` | `List?` | Array of encoded transaction strings (for Solana) | | `metadata` | `DelegationTransferFromMetadata` | Transfer metadata with chain ID, delegate address, amount, etc. | ## Example ### EVM ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final response = await portal.delegations.transferFrom( chain: 'eip155:11155111', token: 'USDC', fromAddress: '0xowner...', toAddress: '0xrecipient...', amount: '50.0', ); // Sign and send the transaction final tx = response.transactions!.first!; final txResponse = await portal.request( chainId: 'eip155:11155111', method: 'eth_sendTransaction', params: [ {'from': tx.from, 'to': tx.to, 'data': tx.data, 'value': tx.value} ], ); print('Tx hash: ${txResponse.result}'); ``` ### Solana ```dart theme={null} final response = await portal.delegations.transferFrom( chain: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', token: 'USDC', fromAddress: 'ARttPLesu9RiX6H111Pfdc9Y2DhGy1B8P8jyyrD8Cj5b', toAddress: 'GPsPXxoQA51aTJJkNHtFDFYui5hN5UxcFPnheJEHa5Du', amount: '50.0', ); // Sign and send the transaction final encodedTx = response.encodedTransactions!.first!; final txResponse = await portal.request( chainId: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', method: 'sol_signAndSendTransaction', params: [encodedTx], ); print('Tx hash: ${txResponse.result}'); ``` ## Errors | Code | Description | | ------------------------ | ---------------------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `DELEGATION_FAILED` | The delegated transfer failed | | `INSUFFICIENT_ALLOWANCE` | The delegation allowance is insufficient | ## Related * [Manage Token Delegations guide](../guide/delegations) * [delegations.transferAndSubmit](./delegations-transferandsubmit) — transfer, sign, and broadcast in one call * [delegations.approve](./delegations-approve) * [delegations.revoke](./delegations-revoke) * [delegations.getStatus](./delegations-getstatus) # deleteKeychain Source: https://docs.portalhq.io/sdks/flutter/reference/deletekeychain Delete local keychain data. ## Function Signature ```dart theme={null} Future deleteKeychain() ``` ## Description Removes all local wallet data from the device keychain. This deletes the signing shares stored locally. After calling this method, the wallet will need to be recovered from backup to use again. Make sure the wallet is backed up before deleting the keychain. On iOS, this method is a no-op (does nothing) as iOS doesn't support programmatic keychain deletion in the same way. ## Parameters This method takes no parameters. ## Returns **`void`** ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // First, ensure wallet is backed up final isBackedUp = await portal.isWalletBackedUp(); if (isBackedUp) { // Safe to delete local data await portal.deleteKeychain(); print('Local wallet data deleted'); } else { print('Please backup wallet before deleting'); } ``` ## Use Cases ### Clear Data on Logout ```dart theme={null} Future logout() async { final portal = Portal(); // Ensure backup exists if (await portal.isWalletBackedUp()) { await portal.deleteKeychain(); } // Clear other app data... } ``` ### Reset Wallet ```dart theme={null} Future resetWallet() async { final portal = Portal(); // Delete local data await portal.deleteKeychain(); // Create new wallet final addresses = await portal.createWallet(); print('New wallet created: ${addresses.ethereum}'); } ``` ## Errors | Code | Description | | ----------------- | ------------------------------ | | `NOT_INITIALIZED` | Portal was not initialized | | `DELETE_FAILED` | Failed to delete keychain data | ## Related * [recoverWallet](./recoverwallet) * [isWalletBackedUp](./iswalletbackedup) # doesWalletExist Source: https://docs.portalhq.io/sdks/flutter/reference/doeswalletexist Check if a wallet exists. ## Function Signature ```dart theme={null} Future doesWalletExist([String? chainId]) ``` ## Description Checks if a wallet exists for the client. Can optionally check for a specific chain. ## Parameters | Parameter | Type | Required | Description | | --------- | -------- | -------- | ----------------------------------------------------------- | | `chainId` | `String` | No | Optional chain ID to check. If null, checks for any wallet. | ## Returns **`bool`** - `true` if a wallet exists, `false` otherwise. ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Check if any wallet exists final exists = await portal.doesWalletExist(); if (exists) { print('Wallet exists'); } else { print('No wallet found'); } ``` ### Check Specific Chain ```dart theme={null} // Check if EVM wallet exists final evmExists = await portal.doesWalletExist('eip155:1'); // Check if Solana wallet exists final solExists = await portal.doesWalletExist('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'); ``` ## Errors | Code | Description | | ----------------- | -------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | ## Related * [Create a wallet guide](../guide/create-a-wallet) * [createWallet](./createwallet) * [getAddresses](./getaddresses) * [isWalletOnDevice](./iswalletondevice) # ejectPrivateKeys Source: https://docs.portalhq.io/sdks/flutter/reference/ejectprivatekeys Export private keys from the MPC system. ## Function Signature ```dart theme={null} Future> ejectPrivateKeys({ required PortalBackupMethod method, String? password, String? cipherText, String? orgBackupShare, String? orgSolanaBackupShare, }) ``` ## Description Exports the full private keys from the MPC system. This requires authentication using one of your backup methods. For Self-Managed Backups, pass the encrypted client share via `cipherText` and the raw custodian share(s) via `orgBackupShare` (secp256k1 / EVM) and `orgSolanaBackupShare` (ed25519 / Solana). Under Portal-Managed Backups these parameters are not required — Portal supplies the custodian share. **WARNING**: This operation exposes your full private keys. Once ejected, the security guarantees of MPC are lost. Use with extreme caution. ## Parameters | Parameter | Type | Required | Description | | ---------------------- | -------------------- | -------- | -------------------------------------------------------------------------------------------- | | `method` | `PortalBackupMethod` | Yes | The backup method to use for authentication | | `password` | `String` | No | Required when using `PortalBackupMethod.password` | | `cipherText` | `String` | No | Required for Self-Managed Backups: the encrypted client backup share | | `orgBackupShare` | `String` | No | Required for Self-Managed Backups: raw custodian backup share for the secp256k1 / EVM curve | | `orgSolanaBackupShare` | `String` | No | Required for Self-Managed Backups: raw custodian backup share for the ed25519 / Solana curve | ## Returns **`Map`** - A map of namespace to private key: | Key | Description | | -------- | ---------------------------- | | `eip155` | The Ethereum/EVM private key | | `solana` | The Solana private key | ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Portal-Managed Backups: pass only method (and password when applicable). final keys = await portal.ejectPrivateKeys( method: PortalBackupMethod.password, password: 'user-password', ); // keys is a Map keyed by namespace: // keys['eip155'] -> EVM private key // keys['solana'] -> Solana private key // // ⚠️ Never log or print the values. Hand them off to your secure export // flow (e.g. show in a confirmation modal, encrypt for transfer to // another wallet manager). final evmKey = keys['eip155']; final solanaKey = keys['solana']; ``` ### Self-Managed Backups ```dart theme={null} final keys = await portal.ejectPrivateKeys( method: PortalBackupMethod.password, password: 'user-password', cipherText: cipherText, orgBackupShare: orgBackupShare, orgSolanaBackupShare: orgSolanaBackupShare, ); ``` ## Errors | Code | Description | | ----------------------- | ----------------------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `EJECT_FAILED` | The eject operation failed | | `INVALID_PASSWORD` | The provided password is incorrect | | `AUTHENTICATION_FAILED` | Failed to authenticate with backup method | ## Related * [Eject a wallet guide](../guide/eject-a-wallet) * [backupWallet](./backupwallet) * [getClient](./getclient) # eth_signUserOperation Source: https://docs.portalhq.io/sdks/flutter/reference/eth_signuseroperation Sign an ERC-4337 UserOperation without broadcasting it. ## Function Signature ```dart theme={null} Future request({ required String chainId, required String method, // 'eth_signUserOperation' required List params, String? signatureApprovalMemo, }) ``` ## Description Signs an ERC-4337 UserOperation using the client's MPC key without broadcasting it to the network. This method is invoked via `portal.request()` with the method set to `eth_signUserOperation`. The signed UserOperation can then be submitted through a separate backend (e.g., Pimlico or a custom bundler endpoint). This method requires the client to be created with [Account Abstraction](../../../resources/account-abstraction) enabled (`isAccountAbstracted: true`). Non-AA clients will receive a `METHOD_UNSUPPORTED` error. ## Parameters The `params` array takes a single object with the following fields: | Parameter | Type | Required | Description | | ---------------------- | -------- | -------- | ------------------------------------------- | | `to` | `String` | Yes | The recipient or contract address | | `value` | `String` | No | Value in wei (hex-encoded) | | `data` | `String` | No | Calldata (hex-encoded) | | `gas` | `String` | No | Gas limit (hex-encoded) | | `maxFeePerGas` | `String` | No | Max fee per gas for EIP-1559 (hex-encoded) | | `maxPriorityFeePerGas` | `String` | No | Max priority fee for EIP-1559 (hex-encoded) | | `nonce` | `String` | No | Transaction nonce (hex-encoded) | ## Returns **`PortalProviderResponse`** - An object containing: | Property | Type | Description | | -------- | --------- | ------------------------------------------------------------- | | `result` | `dynamic` | A hex-encoded JSON string containing the signed UserOperation | | `error` | `dynamic` | Error information if the call failed | ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Sign a UserOperation on Sepolia testnet final result = await portal.request( chainId: 'eip155:11155111', method: 'eth_signUserOperation', params: [ { 'to': '0xRecipientAddress', 'value': '0x0', 'data': '0x', } ], ); if (result.error != null) { print('Error: ${result.error}'); } else { print('Signed UserOp: ${result.result}'); } ``` ### With EIP-1559 Gas Parameters ```dart theme={null} final result = await portal.request( chainId: 'eip155:11155111', method: 'eth_signUserOperation', params: [ { 'to': '0xRecipientAddress', 'value': '0xDE0B6B3A7640000', // 1 ETH in wei 'data': '0x', 'maxFeePerGas': '0x3B9ACA00', 'maxPriorityFeePerGas': '0x3B9ACA00', } ], ); ``` ## Errors | Code | Description | | -------------------- | ---------------------------------------------------- | | `METHOD_UNSUPPORTED` | The client does not have Account Abstraction enabled | | `NOT_INITIALIZED` | Portal was not initialized | | `RPC_ERROR` | The RPC call returned an error | ## Related * [Sign a user operation guide](../guide/sign-a-user-operation) * [request](./request) * [sendTransaction](./sendtransaction) * [Account Abstraction](../../../resources/account-abstraction) # evaluateTransaction Source: https://docs.portalhq.io/sdks/flutter/reference/evaluatetransaction Evaluate a transaction for security risks. ## Function Signature ```dart theme={null} Future evaluateTransaction({ required String chainId, required String to, String? from, String? value, String? data, String? gas, String? maxFeePerGas, String? maxPriorityFeePerGas, PortalEvaluateTransactionOperationType? operationType, }) ``` ## Description Evaluates a transaction for security risks using Blockaid. This helps identify malicious contracts, phishing attempts, and other security threats before sending a transaction. ## Parameters | Parameter | Type | Required | Description | | ---------------------- | ---------------------------------------- | -------- | ------------------------------ | | `chainId` | `String` | Yes | The chain ID in CAIP-2 format | | `to` | `String` | Yes | The recipient/contract address | | `from` | `String` | No | The sender address | | `value` | `String` | No | Value in wei (hex string) | | `data` | `String` | No | Transaction data (hex string) | | `gas` | `String` | No | Gas limit (hex string) | | `maxFeePerGas` | `String` | No | Max fee per gas (hex string) | | `maxPriorityFeePerGas` | `String` | No | Max priority fee (hex string) | | `operationType` | `PortalEvaluateTransactionOperationType` | No | Type of evaluation to perform | ### PortalEvaluateTransactionOperationType | Value | Description | | ------------ | -------------------------------------- | | `validation` | Only validate the transaction | | `simulation` | Only simulate the transaction | | `all` | Perform both validation and simulation | ## Returns **`PortalEvaluateTransactionResult`** - An object containing: | Property | Type | Description | | ---------------- | --------- | --------------------------------------------------------------- | | `result` | `String?` | The overall assessment (e.g., `Benign`, `Warning`, `Malicious`) | | `reason` | `String?` | The reason for the assessment | | `classification` | `String?` | The classification category of the risk | | `description` | `String?` | A human-readable description of the findings | | `status` | `String?` | The status of the evaluation | ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final result = await portal.evaluateTransaction( chainId: 'eip155:1', to: '0xContractAddress', value: '0x0', data: '0xa9059cbb...', operationType: PortalEvaluateTransactionOperationType.all, ); // Check the result if (result.result == 'Benign') { print('Transaction appears safe'); } else if (result.result == 'Warning') { print('Proceed with caution'); print('Classification: ${result.classification}'); print('Description: ${result.description}'); } else if (result.result == 'Malicious') { print('Transaction blocked'); print('Reason: ${result.reason}'); } ``` ## Errors | Code | Description | | ------------------- | ------------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `EVALUATION_FAILED` | The evaluation operation failed | ## Related * [Evaluate a transaction guide](../guide/evaluate-a-transaction) # getAddresses / getAddress Source: https://docs.portalhq.io/sdks/flutter/reference/getaddresses Get wallet addresses. ## getAddresses ### Function Signature ```dart theme={null} Future getAddresses() ``` ### Description Returns the addresses for the client's wallet. Addresses come from the client's wallet metadata (provided by the Portal API), **not** from the signing shares on the device — so this returns addresses whenever the client has a wallet, regardless of whether its signing shares are on the current device. A returned address does **not** mean the wallet can sign on this device — it only tells you the client has a wallet. To check whether the signing shares are on the current device, use [`isWalletOnDevice`](./iswalletondevice). ### Parameters This method takes no parameters. ### Returns **`PortalGetAddressesResult`** - An object containing: | Property | Type | Description | | ----------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `hasWallet` | `bool` | Whether the client has a wallet (i.e. addresses are available). This reflects wallet existence, **not** whether the signing shares are on this device — use [`isWalletOnDevice`](./iswalletondevice) for that. | | `addresses` | `PortalAddresses` | The wallet addresses | ### PortalAddresses | Property | Type | Description | | ---------- | --------- | ------------------------- | | `ethereum` | `String?` | The EVM wallet address | | `solana` | `String?` | The Solana wallet address | ### Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final result = await portal.getAddresses(); if (result.hasWallet) { print('EVM address: ${result.addresses.ethereum}'); print('Solana address: ${result.addresses.solana}'); } else { print('No wallet found'); } ``` *** ## getAddress ### Function Signature ```dart theme={null} Future getAddress(String chainId) ``` ### Description Get the wallet address for a specific chain. Like `getAddresses`, it comes from the client's Portal-provided wallet metadata, not from the on-device signing shares, so a non-null address only indicates the client has a wallet — it does **not** mean the signing shares are on this device. Use [`isWalletOnDevice`](./iswalletondevice) to determine on-device signing availability. ### Parameters | Parameter | Type | Required | Description | | --------- | -------- | -------- | ----------------------------- | | `chainId` | `String` | Yes | The chain ID in CAIP-2 format | ### Returns **`String?`** - The wallet address for the specified chain, or `null` if the client has no wallet. ### Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Get Ethereum mainnet address final ethAddress = await portal.getAddress('eip155:1'); // Get Solana mainnet address final solAddress = await portal.getAddress('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'); print('ETH address: $ethAddress'); print('SOL address: $solAddress'); ``` ## Errors | Code | Description | | ----------------- | -------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | ## Related * [Create a wallet guide](../guide/create-a-wallet) * [createWallet](./createwallet) * [doesWalletExist](./doeswalletexist) * [isWalletOnDevice](./iswalletondevice) — check whether the signing shares are on the current device # getAssets Source: https://docs.portalhq.io/sdks/flutter/reference/getassets Get asset balances for a chain. ## Function Signature ```dart theme={null} Future getAssets(String chainId) ``` ## Description Retrieves both native token balance and ERC-20/token balances for the wallet on the specified chain. ## Parameters | Parameter | Type | Required | Description | | --------- | -------- | -------- | ----------------------------- | | `chainId` | `String` | Yes | The chain ID in CAIP-2 format | ## Returns **`PortalAssetsResponse`** - An object containing: | Property | Type | Description | | --------------- | -------------------- | ----------------------------- | | `nativeBalance` | `List` | List of native token balances | | `tokenBalances` | `List` | List of token balances | ### PortalAsset | Property | Type | Description | | -------------- | ---------------------- | ---------------------------------------- | | `symbol` | `String` | Token symbol (e.g., "ETH", "USDC") | | `name` | `String` | Token name | | `balance` | `String` | The formatted balance amount | | `decimals` | `int` | Token decimals | | `tokenAddress` | `String?` | Token contract address (null for native) | | `rawBalance` | `String?` | Raw balance in smallest unit | | `metadata` | `PortalAssetMetadata?` | Additional token metadata | ### PortalAssetMetadata | Property | Type | Description | | ----------- | --------- | -------------- | | `logoUri` | `String?` | Token logo URL | | `tokenType` | `String?` | Type of token | ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final assets = await portal.getAssets('eip155:1'); // Native balance for (final native in assets.nativeBalance) { if (native != null) { print('${native.symbol} balance: ${native.balance}'); } } // Token balances for (final token in assets.tokenBalances) { if (token != null) { print('${token.symbol}: ${token.balance}'); } } ``` ## Errors | Code | Description | | ----------------- | -------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `FETCH_FAILED` | Failed to fetch assets | ## Related * [getNftAssets](./getnftassets) * [getTransactions](./gettransactions) # getBalances Source: https://docs.portalhq.io/sdks/flutter/reference/getbalances Retrieves a flat list of token balances for the wallet on a specific chain. ## Function Signature ```dart theme={null} Future> getBalances(String chainId) ``` ## Description Returns a flat list of token balances for the wallet on the specified chain — each entry includes a contract address, balance, and (optionally) token name and symbol. This is a lighter-weight alternative to [`getAssets`](./getassets) when you only need basic balance information without the additional metadata (logos, decimals, asset type) that `getAssets` returns. ## Parameters | Parameter | Type | Required | Description | | --------- | -------- | -------- | ---------------------------------------------------------------------- | | `chainId` | `String` | Yes | The chain ID in CAIP-2 format (e.g., `eip155:1` for Ethereum mainnet). | ## Returns **`List`** — A list of token balances on the chain. ### PortalBalance | Property | Type | Description | | ----------------- | --------- | ------------------------------------------------------------- | | `contractAddress` | `String` | The contract address of the token. | | `balance` | `String` | The token balance as a string (in the token's smallest unit). | | `name` | `String?` | Optional token name. | | `symbol` | `String?` | Optional token symbol (e.g., `USDC`). | ## Example ### Basic balance check ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final balances = await portal.getBalances('eip155:1'); for (final balance in balances) { print('${balance.symbol ?? balance.contractAddress}: ${balance.balance}'); } ``` ### Multi-chain balances ```dart theme={null} Future printBalancesAcrossChains(Portal portal) async { final chains = ['eip155:1', 'eip155:137', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp']; for (final chainId in chains) { final balances = await portal.getBalances(chainId); print('--- $chainId ---'); for (final balance in balances) { final label = balance.symbol ?? balance.name ?? balance.contractAddress; print(' $label: ${balance.balance}'); } } } ``` ### Filtering non-zero balances ```dart theme={null} final balances = await portal.getBalances('eip155:1'); final nonZero = balances .where((b) => (BigInt.tryParse(b.balance) ?? BigInt.zero) != BigInt.zero) .toList(); print('${nonZero.length} tokens with non-zero balances'); ``` ## Errors Throws a `PortalException` on failure: | Code | Description | | -------------------- | --------------------------- | | `NOT_INITIALIZED` | Portal was not initialized. | | `GET_BALANCES_ERROR` | Failed to fetch balances. | ```dart theme={null} try { final balances = await portal.getBalances('eip155:1'); // Use the balances... } on PortalException catch (e) { print('Failed to fetch balances: ${e.code} - ${e.message}'); } ``` ## Implementation Notes * Balances are returned as raw string values in the token's smallest unit (e.g., wei for ETH, lamports for SOL). Apply token decimals when displaying balances to users. * Both EVM and Solana chains are supported — pass the appropriate CAIP-2 chain ID. * For richer metadata (logos, asset type, native vs. token grouping), use [`getAssets`](./getassets) instead. * Consider rate limiting when polling balances frequently. ## Related * [getAssets](./getassets) — richer per-asset metadata (logos, decimals, native + token grouping) * [getNftAssets](./getnftassets) * [getTransactions](./gettransactions) # getClient Source: https://docs.portalhq.io/sdks/flutter/reference/getclient Get client information. ## Function Signature ```dart theme={null} Future getClient() ``` ## Description Retrieves client information from the Portal backend, including the client ID, list of wallets, and environment configuration. ## Parameters This method takes no parameters. ## Returns **`PortalClientInfo`** - An object containing: | Property | Type | Description | | ------------- | -------------------------- | ------------------------- | | `id` | `String` | The client ID | | `wallets` | `List` | List of wallets | | `environment` | `PortalClientEnvironment?` | Environment configuration | ### PortalWallet | Property | Type | Description | | ---------------- | --------- | ----------------------------------------------------------------------------- | | `id` | `String` | Wallet ID | | `curve` | `String` | The cryptographic curve used (e.g., "secp256k1", "ed25519") | | `ejectableUntil` | `String?` | ISO timestamp until which private keys can be ejected (null if not ejectable) | ### PortalClientEnvironment | Property | Type | Description | | ------------------------- | -------- | ------------------------------------------------------------------------------------------------------------- | | `id` | `String` | The environment ID | | `name` | `String` | Environment name (e.g., "production", "sandbox") | | `backupWithPortalEnabled` | `bool?` | `true` when the client is configured for Portal-Managed Backups; `false` (or `null`) for Self-Managed Backups | ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final clientInfo = await portal.getClient(); print('Client ID: ${clientInfo.id}'); for (final wallet in clientInfo.wallets) { if (wallet != null) { print('Wallet ID: ${wallet.id}'); print('Curve: ${wallet.curve}'); if (wallet.ejectableUntil != null) { print('Ejectable until: ${wallet.ejectableUntil}'); } } } ``` ## Use Cases ### Check Eject Availability ```dart theme={null} final clientInfo = await portal.getClient(); for (final wallet in clientInfo.wallets) { if (wallet != null && wallet.ejectableUntil != null) { final ejectableUntil = DateTime.parse(wallet.ejectableUntil!); if (DateTime.now().isBefore(ejectableUntil)) { print('Wallet ${wallet.id} can be ejected until ${wallet.ejectableUntil}'); } } } ``` ### Detect Portal-Managed vs Self-Managed Backups ```dart theme={null} final clientInfo = await portal.getClient(); final portalManaged = clientInfo.environment?.backupWithPortalEnabled ?? false; ``` ## Errors | Code | Description | | ----------------- | ---------------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `FETCH_FAILED` | Failed to fetch client information | ## Related * [backupWallet](./backupwallet) * [ejectPrivateKeys](./ejectprivatekeys) # getNftAssets Source: https://docs.portalhq.io/sdks/flutter/reference/getnftassets Get NFT assets for a chain. ## Function Signature ```dart theme={null} Future> getNftAssets(String chainId) ``` ## Description Retrieves all NFTs owned by the wallet on the specified chain. ## Parameters | Parameter | Type | Required | Description | | --------- | -------- | -------- | ----------------------------- | | `chainId` | `String` | Yes | The chain ID in CAIP-2 format | ## Returns **`List`** - A list of NFT assets. ### PortalNftAsset | Property | Type | Description | | ----------------- | -------------------- | -------------------------------------- | | `tokenId` | `String?` | The NFT token ID | | `contractAddress` | `String?` | The NFT contract address | | `name` | `String?` | NFT name | | `description` | `String?` | NFT description | | `imageUrl` | `String?` | NFT image URL | | `tokenType` | `String?` | Token type (e.g., "ERC721", "ERC1155") | | `metadata` | `PortalNftMetadata?` | Additional NFT metadata | ### PortalNftMetadata | Property | Type | Description | | ------------- | --------- | ----------------------------- | | `name` | `String?` | NFT name from metadata | | `description` | `String?` | NFT description from metadata | | `image` | `String?` | NFT image URL from metadata | ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final nfts = await portal.getNftAssets('eip155:1'); for (final nft in nfts) { print('NFT: ${nft.name ?? nft.metadata?.name}'); print('Token ID: ${nft.tokenId}'); print('Contract: ${nft.contractAddress}'); print('Image: ${nft.imageUrl ?? nft.metadata?.image}'); print('Type: ${nft.tokenType}'); } ``` ## Errors | Code | Description | | ----------------- | -------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `FETCH_FAILED` | Failed to fetch NFT assets | ## Related * [getAssets](./getassets) * [getTransactions](./gettransactions) # getTransactionDetails Source: https://docs.portalhq.io/sdks/flutter/reference/gettransactiondetails Get full details for a specific transaction by chain and signature. ## Function Signature ```dart theme={null} Future getTransactionDetails( String chain, String signature, ) ``` ## Description Retrieves the full details of a specific transaction identified by its chain and signature (transaction hash). Supports EVM, EVM user operations, Solana, Bitcoin, Stellar, and Tron chains. ## Parameters | Parameter | Type | Required | Description | | ----------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `chain` | `String` | Yes | The chain ID in CAIP-2 format (e.g. `eip155:1`). Note: this parameter is named `chain` (not `chainId`) to match the underlying native SDK API. | | `signature` | `String` | Yes | The transaction hash or signature to look up | ## Returns **`PortalGetTransactionDetailsResponse`** — An object containing transaction metadata and chain-specific data. ### PortalGetTransactionDetailsResponse | Property | Type | Description | | ------------------------ | --------- | -------------------------------------------------------------------------------- | | `chainId` | `String` | The chain ID the transaction belongs to | | `signature` | `String` | The transaction hash or signature | | `evmTransactionJson` | `String?` | JSON string of the EVM transaction (present for standard EVM chains) | | `evmUserOperationJson` | `String?` | JSON string of the EVM user operation (present for ERC-4337 account abstraction) | | `solanaTransactionJson` | `String?` | JSON string of the Solana transaction | | `bitcoinTransactionJson` | `String?` | JSON string of the Bitcoin transaction | | `stellarTransactionJson` | `String?` | JSON string of the Stellar transaction | | `tronTransactionJson` | `String?` | JSON string of the Tron transaction | Only one of the chain-specific JSON fields will be non-null, depending on the chain. ## Example ```dart theme={null} import 'dart:convert'; import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Look up a specific EVM transaction final details = await portal.getTransactionDetails( 'eip155:11155111', '0xabc123...', ); print('Chain: ${details.chainId}'); print('Signature: ${details.signature}'); if (details.evmTransactionJson != null) { final tx = jsonDecode(details.evmTransactionJson!); print("Value: ${tx['value']}"); print("Gas: ${tx['gas']}"); print("Block: ${tx['blockHash']}"); } else if (details.solanaTransactionJson != null) { final tx = jsonDecode(details.solanaTransactionJson!); print("Slot: ${tx['slot']}"); } ``` ### Fetch details for the most recent transaction ```dart theme={null} // Get the latest transaction hash first final transactions = await portal.getTransactions( 'eip155:1', limit: 1, order: PortalTransactionOrder.desc, ); if (transactions.isNotEmpty && transactions.first.hash != null) { final details = await portal.getTransactionDetails( 'eip155:1', transactions.first.hash!, ); print('Details: ${details.evmTransactionJson}'); } ``` ## Errors | Code | Description | | ------------------------------- | --------------------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `GET_TRANSACTION_DETAILS_ERROR` | Failed to fetch the transaction details | ## Related * [getTransactions](./gettransactions) * [evaluateTransaction](./evaluatetransaction) # getTransactions Source: https://docs.portalhq.io/sdks/flutter/reference/gettransactions Get transaction history for a chain. ## Function Signature ```dart theme={null} Future> getTransactions( String chainId, { int? limit, int? offset, PortalTransactionOrder? order, }) ``` ## Description Retrieves historical transactions for the wallet on the specified chain. ## Parameters | Parameter | Type | Required | Description | | --------- | ------------------------ | -------- | ------------------------------------------- | | `chainId` | `String` | Yes | The chain ID in CAIP-2 format | | `limit` | `int` | No | Maximum number of transactions to return | | `offset` | `int` | No | Number of transactions to skip (pagination) | | `order` | `PortalTransactionOrder` | No | Sort order | ### PortalTransactionOrder | Value | Description | | ------ | ------------------------------- | | `asc` | Ascending order (oldest first) | | `desc` | Descending order (newest first) | ## Returns **`List`** - A list of transaction objects. ### PortalTransaction | Property | Type | Description | | ------------------ | --------- | ---------------------- | | `hash` | `String?` | Transaction hash | | `from` | `String?` | Sender address | | `to` | `String?` | Recipient address | | `value` | `String?` | Transaction value | | `blockHash` | `String?` | Block hash | | `blockNumber` | `String?` | Block number | | `blockTimestamp` | `String?` | Block timestamp | | `chainId` | `String?` | Chain ID | | `gas` | `String?` | Gas used | | `gasPrice` | `String?` | Gas price | | `input` | `String?` | Transaction input data | | `nonce` | `String?` | Transaction nonce | | `transactionIndex` | `String?` | Index in the block | ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Get recent transactions final transactions = await portal.getTransactions( 'eip155:1', limit: 10, order: PortalTransactionOrder.desc, ); for (final tx in transactions) { print('Hash: ${tx.hash}'); print('From: ${tx.from}'); print('To: ${tx.to}'); print('Value: ${tx.value}'); print('Timestamp: ${tx.blockTimestamp}'); } ``` ### Pagination ```dart theme={null} // First page final page1 = await portal.getTransactions( 'eip155:1', limit: 20, offset: 0, ); // Second page final page2 = await portal.getTransactions( 'eip155:1', limit: 20, offset: 20, ); ``` ## Errors | Code | Description | | ----------------- | ---------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `FETCH_FAILED` | Failed to fetch transactions | ## Related * [getAssets](./getassets) * [getNftAssets](./getnftassets) # isWalletBackedUp Source: https://docs.portalhq.io/sdks/flutter/reference/iswalletbackedup Check if the wallet is backed up. ## Function Signature ```dart theme={null} Future isWalletBackedUp([String? chainId]) ``` ## Description Checks if the wallet has been backed up. Can optionally check for a specific chain. ## Parameters | Parameter | Type | Required | Description | | --------- | -------- | -------- | ----------------------------------------------------------------- | | `chainId` | `String` | No | Optional chain ID to check. If null, checks global backup status. | ## Returns **`bool`** - `true` if the wallet is backed up, `false` otherwise. ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final isBackedUp = await portal.isWalletBackedUp(); if (isBackedUp) { print('Wallet is backed up'); } else { print('Wallet needs backup'); } ``` ## Errors | Code | Description | | ----------------- | -------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | ## Related * [Back up a wallet guide](../guide/back-up-a-wallet) * [backupWallet](./backupwallet) * [isWalletRecoverable](./iswalletrecoverable) # isWalletOnDevice Source: https://docs.portalhq.io/sdks/flutter/reference/iswalletondevice Check if the wallet is stored on the device. ## Function Signature ```dart theme={null} Future isWalletOnDevice([String? chainId]) ``` ## Description Checks if the wallet signing shares are stored on the current device. ## Parameters | Parameter | Type | Required | Description | | --------- | -------- | -------- | ----------------------------------------------------------- | | `chainId` | `String` | No | Optional chain ID to check. If null, checks for any wallet. | ## Returns **`bool`** - `true` if the wallet is on device, `false` otherwise. ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final isOnDevice = await portal.isWalletOnDevice(); if (isOnDevice) { print('Wallet is on this device'); } else { print('Wallet needs to be recovered'); } ``` ## Use Case This is useful for determining if a user needs to recover their wallet: ```dart theme={null} final exists = await portal.doesWalletExist(); final onDevice = await portal.isWalletOnDevice(); if (exists && !onDevice) { // Wallet exists but needs to be recovered to this device print('Please recover your wallet'); } ``` ## Errors | Code | Description | | ----------------- | -------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | ## Related * [doesWalletExist](./doeswalletexist) * [isWalletRecoverable](./iswalletrecoverable) * [recoverWallet](./recoverwallet) # isWalletRecoverable Source: https://docs.portalhq.io/sdks/flutter/reference/iswalletrecoverable Check if the wallet can be recovered. ## Function Signature ```dart theme={null} Future isWalletRecoverable([String? chainId]) ``` ## Description Checks if the wallet can be recovered. A wallet is recoverable if it has been backed up. ## Parameters | Parameter | Type | Required | Description | | --------- | -------- | -------- | ----------------------------------------------------------- | | `chainId` | `String` | No | Optional chain ID to check. If null, checks for any wallet. | ## Returns **`bool`** - `true` if the wallet can be recovered, `false` otherwise. ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final isRecoverable = await portal.isWalletRecoverable(); if (isRecoverable) { print('Wallet can be recovered'); final methods = await portal.availableRecoveryMethods(); print('Available methods: $methods'); } else { print('No recovery options available'); } ``` ## Errors | Code | Description | | ----------------- | -------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | ## Related * [Recover a wallet guide](../guide/recover-a-wallet) * [recoverWallet](./recoverwallet) * [availableRecoveryMethods](./availablerecoverymethods) * [isWalletBackedUp](./iswalletbackedup) # rawSign Source: https://docs.portalhq.io/sdks/flutter/reference/rawsign Sign a raw message without any prefix. ## Function Signature ```dart theme={null} Future rawSign({ required String chainId, required String message, String? signatureApprovalMemo, }) ``` ## Description Signs a raw message without adding any prefix. Unlike `signMessage`, this does not prepend the Ethereum message prefix. Use this for signing raw data or for chains that don't use Ethereum's message prefix. ## Parameters | Parameter | Type | Required | Description | | ----------------------- | -------- | -------- | ----------------------------------------- | | `chainId` | `String` | Yes | The chain ID in CAIP-2 format | | `message` | `String` | Yes | The message to sign (must be hex-encoded) | | `signatureApprovalMemo` | `String` | No | Optional memo for signature approval | ## Returns **`String`** - The signature as a hex string. ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Message must be hex-encoded // "test" in hex is "74657374" final signature = await portal.rawSign( chainId: 'eip155:1', message: '74657374', signatureApprovalMemo: 'Signing test message', ); print('Raw signature: $signature'); ``` ## Chain-Specific Signing The curve used for signing depends on the chain ID: | Chain Type | Curve | | ---------- | --------- | | `eip155:*` | SECP256K1 | | `solana:*` | ED25519 | ```dart theme={null} // SECP256K1 signing (Ethereum) final ethSignature = await portal.rawSign( chainId: 'eip155:1', message: '74657374', ); // ED25519 signing (Solana) final solSignature = await portal.rawSign( chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', message: '74657374', ); ``` ## Errors | Code | Description | | ----------------- | ---------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `SIGNING_FAILED` | The signing operation failed | | `INVALID_HEX` | The message is not valid hex | ## Related * [Sign a transaction guide](../guide/sign-a-transaction) * [signMessage](./signmessage) * [request](./request) # receiveTestnetAsset Source: https://docs.portalhq.io/sdks/flutter/reference/receivetestnetasset Request testnet assets for development. ## Function Signature ```dart theme={null} Future receiveTestnetAsset({ required String chainId, required String amount, required String token, }) ``` ## Description Requests testnet assets for development and testing. This method only works on supported testnet networks. ## Parameters | Parameter | Type | Required | Description | | --------- | -------- | -------- | ---------------------------------------- | | `chainId` | `String` | Yes | The testnet chain ID in CAIP-2 format | | `amount` | `String` | Yes | The amount to request | | `token` | `String` | Yes | Token symbol (e.g., `"NATIVE"`, `"ETH"`) | ## Returns **`PortalFundResult`** - An object containing: | Property | Type | Description | | ----------------- | --------- | ----------------------------------- | | `success` | `bool` | Whether the request was successful | | `transactionHash` | `String?` | The transaction hash | | `error` | `String?` | Error message if the request failed | ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Request testnet ETH on Sepolia final result = await portal.receiveTestnetAsset( chainId: 'eip155:11155111', // Sepolia amount: '0.01', token: 'NATIVE', ); if (result.success) { print('Funded! TX: ${result.transactionHash}'); } else { print('Failed: ${result.error}'); } ``` ### Monad Testnet ```dart theme={null} final result = await portal.receiveTestnetAsset( chainId: 'eip155:10143', // Monad Testnet amount: '0.01', token: 'NATIVE', ); ``` ## Supported Networks | Network | Chain ID | | ------------- | ----------------- | | Sepolia | `eip155:11155111` | | Monad Testnet | `eip155:10143` | For other testnet tokens or networks, check the [testnet faucets page](../../../resources/testnet-faucets). ## Errors | Code | Description | | --------------------- | -------------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `FAUCET_ERROR` | Failed to request testnet assets | | `UNSUPPORTED_NETWORK` | The network is not supported | ## Related * [Send tokens guide](../guide/send-tokens) * [sendAsset](./sendasset) # recoverWallet Source: https://docs.portalhq.io/sdks/flutter/reference/recoverwallet Recover a wallet from a backup. ## Function Signature ```dart theme={null} Future recoverWallet({ required PortalBackupMethod method, String? password, String? cipherText, }) ``` ## Description Recovers a wallet from a backup using the specified backup method. This is used when a user needs to access their wallet on a new device. ## Parameters | Parameter | Type | Required | Description | | ------------ | -------------------- | -------- | ----------------------------------------------------------- | | `method` | `PortalBackupMethod` | Yes | The backup method that was used | | `password` | `String` | No | Required when recovering with `PortalBackupMethod.password` | | `cipherText` | `String` | No | The encrypted backup data (for self-managed backups) | ## Returns **`PortalAddresses`** - An object containing the recovered wallet addresses: | Property | Type | Description | | ---------- | --------- | ------------------------ | | `ethereum` | `String?` | The Ethereum/EVM address | | `solana` | `String?` | The Solana address | ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Recover with password final addresses = await portal.recoverWallet( method: PortalBackupMethod.password, password: 'user-password', ); print('Recovered EVM address: ${addresses.ethereum}'); print('Recovered Solana address: ${addresses.solana}'); ``` ## Self-Managed Backup Recovery ```dart theme={null} // Recover with cipher text (self-managed backups) final addresses = await portal.recoverWallet( method: PortalBackupMethod.password, password: 'user-password', cipherText: 'stored-cipher-text', ); ``` ## Errors | Code | Description | | ------------------ | ---------------------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `RECOVERY_FAILED` | The recovery operation failed | | `INVALID_PASSWORD` | The provided password is incorrect | | `BACKUP_NOT_FOUND` | No backup found for the specified method | ## Related * [Recover a wallet guide](../guide/recover-a-wallet) * [backupWallet](./backupwallet) * [availableRecoveryMethods](./availablerecoverymethods) # request Source: https://docs.portalhq.io/sdks/flutter/reference/request Make a generic JSON-RPC request. ## Function Signature ```dart theme={null} Future request({ required String chainId, required String method, required List params, String? signatureApprovalMemo, }) ``` ## Description Make a generic JSON-RPC request to the blockchain. This allows you to call any RPC method supported by the chain. ## Parameters | Parameter | Type | Required | Description | | ----------------------- | --------------- | -------- | ------------------------------------ | | `chainId` | `String` | Yes | The chain ID in CAIP-2 format | | `method` | `String` | Yes | The JSON-RPC method name | | `params` | `List` | Yes | The method parameters | | `signatureApprovalMemo` | `String` | No | Optional memo for signing operations | ## Returns **`PortalProviderResponse`** - An object containing: | Property | Type | Description | | -------- | --------- | ------------------------------------ | | `result` | `dynamic` | The result of the RPC call | | `error` | `dynamic` | Error information if the call failed | ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Get account balance final response = await portal.request( chainId: 'eip155:1', method: 'eth_getBalance', params: ['0xYourAddress', 'latest'], ); print('Balance: ${response.result}'); ``` ### Common RPC Methods ```dart theme={null} // Get block number final blockResponse = await portal.request( chainId: 'eip155:1', method: 'eth_blockNumber', params: [], ); // Estimate gas final gasResponse = await portal.request( chainId: 'eip155:1', method: 'eth_estimateGas', params: [{ 'from': '0xFromAddress', 'to': '0xToAddress', 'value': '0x10', }], ); // Call contract (read-only) final callResponse = await portal.request( chainId: 'eip155:1', method: 'eth_call', params: [{ 'to': '0xContractAddress', 'data': '0x70a08231...', // balanceOf encoded }, 'latest'], ); ``` ### Signing Methods ```dart theme={null} // personal_sign final signResponse = await portal.request( chainId: 'eip155:1', method: 'personal_sign', params: ['0x48656c6c6f', '0xYourAddress'], signatureApprovalMemo: 'Sign hello message', ); // eth_sendTransaction final txResponse = await portal.request( chainId: 'eip155:1', method: 'eth_sendTransaction', params: [{ 'from': '0xYourAddress', 'to': '0xRecipientAddress', 'value': '0x10', }], ); ``` ## Errors | Code | Description | | ----------------- | ------------------------------ | | `NOT_INITIALIZED` | Portal was not initialized | | `RPC_ERROR` | The RPC call returned an error | | `NETWORK_ERROR` | Network connectivity issue | ## Related * [Sign a transaction guide](../guide/sign-a-transaction) * [signMessage](./signmessage) * [sendTransaction](./sendtransaction) # sendAsset Source: https://docs.portalhq.io/sdks/flutter/reference/sendasset Send tokens from your wallet to another address. ## Function Signature ```dart theme={null} Future sendAsset({ required String chainId, required String to, required String token, required String amount, String? signatureApprovalMemo, bool? sponsorGas, }) ``` ## Description Send tokens from your Portal wallet to another address. This is the recommended method for simple token transfers. ## Parameters | Parameter | Type | Required | Description | | ----------------------- | -------- | -------- | ---------------------------------------------------- | | `chainId` | `String` | Yes | The chain ID in CAIP-2 format | | `to` | `String` | Yes | The recipient address | | `token` | `String` | Yes | Token address or `"NATIVE"` for native token | | `amount` | `String` | Yes | Amount to send (in human-readable format) | | `signatureApprovalMemo` | `String` | No | Optional memo for transaction approval | | `sponsorGas` | `bool` | No | Whether to use gas sponsorship (Account Abstraction) | ## Returns **`String`** - The transaction hash. For Account Abstraction clients sending on EVM, this is a **UserOperation hash** rather than an on-chain transaction hash (see the warning below). **Account Abstraction clients:** the returned `txHash` is a **UserOperation hash**, not an on-chain transaction hash — it will **not** resolve on a block explorer such as Etherscan or Monadscan. The on-chain transaction hash is only assigned once the bundler includes the UserOperation on-chain. Look up the UserOperation hash on a UserOp explorer such as [JiffyScan](https://jiffyscan.xyz/) to find the resulting transaction hash. See [Account abstraction](../../../resources/account-abstraction). ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Send native token final txHash = await portal.sendAsset( chainId: 'eip155:1', to: '0xRecipientAddress', token: 'NATIVE', amount: '0.01', ); print('Transaction hash: $txHash'); ``` ### Send ERC-20 Token ```dart theme={null} final txHash = await portal.sendAsset( chainId: 'eip155:1', to: '0xRecipientAddress', token: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC amount: '10', ); ``` ### With Gas Sponsorship Control ```dart theme={null} final txHash = await portal.sendAsset( chainId: 'eip155:1', to: '0xRecipientAddress', token: 'NATIVE', amount: '0.01', sponsorGas: false, // User pays gas ); ``` ## Errors | Code | Description | | ---------------------- | -------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `INSUFFICIENT_BALANCE` | Not enough tokens to send | | `TRANSACTION_FAILED` | The transaction failed | ## Related * [Send tokens guide](../guide/send-tokens) * [sendTransaction](./sendtransaction) * [receiveTestnetAsset](./receivetestnetasset) # sendTransaction Source: https://docs.portalhq.io/sdks/flutter/reference/sendtransaction Send a transaction with full parameter control. ## Function Signature ```dart theme={null} Future sendTransaction({ required String chainId, required String to, String? from, String? value, String? data, String? gas, String? gasPrice, String? maxFeePerGas, String? maxPriorityFeePerGas, String? nonce, }) ``` ## Description Signs and broadcasts a transaction to the network with full control over all transaction parameters. ## Parameters | Parameter | Type | Required | Description | | ---------------------- | -------- | -------- | ------------------------------------------- | | `chainId` | `String` | Yes | The chain ID in CAIP-2 format | | `to` | `String` | Yes | The recipient/contract address | | `from` | `String` | No | Sender address (defaults to wallet address) | | `value` | `String` | No | Value in wei (hex string) | | `data` | `String` | No | Transaction data (hex string) | | `gas` | `String` | No | Gas limit (hex string) | | `gasPrice` | `String` | No | Gas price for legacy transactions (hex) | | `maxFeePerGas` | `String` | No | Max fee per gas for EIP-1559 (hex) | | `maxPriorityFeePerGas` | `String` | No | Max priority fee for EIP-1559 (hex) | | `nonce` | `String` | No | Transaction nonce (hex string) | ## Returns **`String`** - The transaction hash. ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Simple ETH transfer final txHash = await portal.sendTransaction( chainId: 'eip155:1', to: '0xRecipientAddress', value: '0xDE0B6B3A7640000', // 1 ETH in wei (hex) ); print('Transaction hash: $txHash'); ``` ### EIP-1559 Transaction ```dart theme={null} final txHash = await portal.sendTransaction( chainId: 'eip155:1', to: '0xRecipientAddress', value: '0x10', maxFeePerGas: '0x3B9ACA00', maxPriorityFeePerGas: '0x3B9ACA00', ); ``` ### Contract Interaction ```dart theme={null} final txHash = await portal.sendTransaction( chainId: 'eip155:1', to: '0xContractAddress', data: '0xa9059cbb...', // Encoded function call gas: '0x5208', ); ``` ## Errors | Code | Description | | -------------------- | --------------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `TRANSACTION_FAILED` | The transaction failed | | `INSUFFICIENT_FUNDS` | Not enough funds for gas or value | ## Related * [Sign a transaction guide](../guide/sign-a-transaction) * [sendAsset](./sendasset) * [request](./request) # setLogLevel Source: https://docs.portalhq.io/sdks/flutter/reference/setloglevel Changes the SDK log verbosity at runtime without reinitializing Portal. ## Function Signature ```dart theme={null} Future setLogLevel(PortalLogLevel level) ``` ## Description Sets the log verbosity for the Portal SDK. The change takes effect immediately across all SDK components, including the native iOS and Android layers. Can be called before or after `initialize()`. When called before initialization, only the Dart-side logger is configured. When called after, the log level is also propagated to the native SDK. ## Parameters | Parameter | Type | Required | Description | | --------- | ---------------- | -------- | ------------------------------- | | `level` | `PortalLogLevel` | Yes | The desired log verbosity level | ### PortalLogLevel values | Value | Description | | ---------------------- | ----------------------------------------------------------------------------- | | `PortalLogLevel.none` | No output. This is the default. | | `PortalLogLevel.error` | Failures only — failed transactions, network errors, binary crashes. | | `PortalLogLevel.warn` | Non-fatal unexpected conditions — deprecated usage, retries, slow responses. | | `PortalLogLevel.info` | Operational milestones — signing started, share generated, connection opened. | | `PortalLogLevel.debug` | Full internals — request/response payloads, timing, state transitions. | Each level includes all levels above it in severity. ## Returns **`void`** ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); await portal.initialize(apiKey: 'YOUR_CLIENT_API_KEY'); // Enable debug logging during development await portal.setLogLevel(PortalLogLevel.debug); // Create wallet — all SDK log output is now visible final addresses = await portal.createWallet(); print('Ethereum address: ${addresses.ethereum}'); // Reduce to errors only await portal.setLogLevel(PortalLogLevel.error); ``` ## Errors | Code | Description | | --------------------- | ---------------------------------- | | `SET_LOG_LEVEL_ERROR` | Failed to set the native log level | ## Related * [Configure log level guide](../guide/configure-log-level) # setPassword Source: https://docs.portalhq.io/sdks/flutter/reference/setpassword Set the password for backup/recovery. ## Function Signature ```dart theme={null} Future setPassword(String password) ``` ## Description Sets the password to be used for password-based backup and recovery operations. This should be called before using `backupWallet` or `recoverWallet` with the password method. The `backupWallet` and `recoverWallet` methods automatically call `setPassword` if a password is provided, so you typically don't need to call this directly. ## Parameters | Parameter | Type | Required | Description | | ---------- | -------- | -------- | ------------------- | | `password` | `String` | Yes | The password to set | ## Returns **`void`** ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // Set password explicitly await portal.setPassword('user-password'); // Then backup with password method final response = await portal.backupWallet( method: PortalBackupMethod.password, ); ``` ### Typical Usage Most commonly, you'll pass the password directly to the backup/recovery methods: ```dart theme={null} // Password is set automatically final response = await portal.backupWallet( method: PortalBackupMethod.password, password: 'user-password', // setPassword is called internally ); ``` ## Errors | Code | Description | | ------------------ | ------------------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `INVALID_PASSWORD` | The password is invalid (e.g., empty) | ## Related * [Back up a wallet guide](../guide/back-up-a-wallet) * [backupWallet](./backupwallet) * [recoverWallet](./recoverwallet) # signMessage Source: https://docs.portalhq.io/sdks/flutter/reference/signmessage Sign a personal message using personal_sign. ## Function Signature ```dart theme={null} Future signMessage({ required String chainId, required String message, }) ``` ## Description Signs a personal message using the `personal_sign` RPC method, which prepends the standard Ethereum message prefix (`"\x19Ethereum Signed Message:\n" + len(message)`) before signing. For signing EIP-712 typed structured data, see [`signTypedData`](./signtypeddata) instead. ## Parameters | Parameter | Type | Required | Description | | --------- | -------- | -------- | ------------------------------------------------- | | `chainId` | `String` | Yes | The chain ID in CAIP-2 format (e.g., `eip155:1`). | | `message` | `String` | Yes | The message to sign (plain text or hex). | ## Returns **`String`** — The signature as a hex string. ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final signature = await portal.signMessage( chainId: 'eip155:1', message: 'Hello, Portal!', ); print('Signature: $signature'); ``` ## Errors Throws a `PortalException` on failure: | Code | Description | | -------------------- | ----------------------------- | | `NOT_INITIALIZED` | Portal was not initialized. | | `SIGN_MESSAGE_ERROR` | The signing operation failed. | ```dart theme={null} try { final signature = await portal.signMessage( chainId: 'eip155:1', message: 'Hello, Portal!', ); } on PortalException catch (e) { print('Failed to sign message: ${e.code} - ${e.message}'); } ``` ## Related * [signTypedData](./signtypeddata) — sign EIP-712 typed structured data * [rawSign](./rawsign) * [request](./request) * [Sign a transaction guide](../guide/sign-a-transaction) # signTypedData Source: https://docs.portalhq.io/sdks/flutter/reference/signtypeddata Sign EIP-712 typed structured data with the Portal Flutter SDK. ## Function Signature ```dart theme={null} Future signTypedData({ required String chainId, required String typedData, }) ``` ## Description Signs typed structured data according to the [EIP-712](https://eips.ethereum.org/EIPS/eip-712) standard using the `eth_signTypedData_v4` RPC method. EIP-712 signatures are commonly used for gasless approvals, off-chain orders, and structured authentication messages. The Flutter SDK accepts the typed data payload as a JSON-encoded string. Use `dart:convert`'s `jsonEncode` to serialize a `Map` into the expected format. ## Parameters | Parameter | Type | Required | Description | | ----------- | -------- | -------- | -------------------------------------------------------- | | `chainId` | `String` | Yes | The chain ID in CAIP-2 format (e.g., `eip155:1`). | | `typedData` | `String` | Yes | The EIP-712 typed data payload as a JSON-encoded string. | ## Returns **`String`** — The signature as a hex string. ## Example ### Basic example ```dart theme={null} import 'dart:convert'; import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final typedData = jsonEncode({ 'types': { 'EIP712Domain': [ {'name': 'name', 'type': 'string'}, {'name': 'version', 'type': 'string'}, {'name': 'chainId', 'type': 'uint256'}, ], 'Message': [ {'name': 'content', 'type': 'string'}, ], }, 'primaryType': 'Message', 'domain': { 'name': 'Example App', 'version': '1', 'chainId': 1, }, 'message': { 'content': 'Hello, Portal!', }, }); final signature = await portal.signTypedData( chainId: 'eip155:1', typedData: typedData, ); print('EIP-712 Signature: $signature'); ``` ### Permit (ERC-20 gasless approval) A common use of EIP-712 is signing an ERC-20 `permit` message to grant token approvals without an on-chain transaction: ```dart theme={null} import 'dart:convert'; import 'package:portal_flutter/portal_flutter.dart'; Future signPermit({ required Portal portal, required String owner, required String spender, required String tokenAddress, required int value, required int nonce, required int deadline, }) async { final typedData = jsonEncode({ 'primaryType': 'Permit', 'types': { 'EIP712Domain': [ {'name': 'name', 'type': 'string'}, {'name': 'version', 'type': 'string'}, {'name': 'chainId', 'type': 'uint256'}, {'name': 'verifyingContract', 'type': 'address'}, ], 'Permit': [ {'name': 'owner', 'type': 'address'}, {'name': 'spender', 'type': 'address'}, {'name': 'value', 'type': 'uint256'}, {'name': 'nonce', 'type': 'uint256'}, {'name': 'deadline', 'type': 'uint256'}, ], }, 'domain': { 'name': 'MyToken', 'version': '1', 'chainId': 1, 'verifyingContract': tokenAddress, }, 'message': { 'owner': owner, 'spender': spender, 'value': value, 'nonce': nonce, 'deadline': deadline, }, }); return portal.signTypedData( chainId: 'eip155:1', typedData: typedData, ); } ``` ## Errors Throws a `PortalException` on failure: | Code | Description | | ----------------------- | ----------------------------- | | `NOT_INITIALIZED` | Portal was not initialized. | | `SIGN_TYPED_DATA_ERROR` | The signing operation failed. | ```dart theme={null} try { final signature = await portal.signTypedData( chainId: 'eip155:1', typedData: typedData, ); } on PortalException catch (e) { print('Failed to sign typed data: ${e.code} - ${e.message}'); } ``` ## Implementation Notes * The `chainId` value inside the `domain` object should match the chain you are signing on. Mismatches will produce a signature that will not verify on-chain. * The Portal Flutter SDK signs typed data via the `eth_signTypedData_v4` RPC method — only EIP-712 v4 is supported. * Verify the signature server-side before using it to authorize an action. ## Related * [signMessage](./signmessage) * [rawSign](./rawsign) * [request](./request) * [Sign a transaction guide](../guide/sign-a-transaction) # swap Source: https://docs.portalhq.io/sdks/flutter/reference/swap Execute a token swap. ## Function Signature ```dart theme={null} Future swap({ required String swapsApiKey, required String chainId, required String buyToken, required String sellToken, required String amount, }) ``` ## Description Execute a token swap on supported chains. This method handles the entire swap process including finding the best route and executing the transaction. ## Parameters | Parameter | Type | Required | Description | | ------------- | -------- | -------- | --------------------------------- | | `swapsApiKey` | `String` | Yes | Your swaps API key | | `chainId` | `String` | Yes | The chain ID in CAIP-2 format | | `buyToken` | `String` | Yes | Token address to buy | | `sellToken` | `String` | Yes | Token address to sell | | `amount` | `String` | Yes | Amount to sell (in smallest unit) | ## Returns **`PortalSwapResult`** - An object containing: | Property | Type | Description | | ----------------- | --------- | ------------------------------- | | `success` | `bool` | Whether the swap was successful | | `transactionHash` | `String?` | The transaction hash | ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final result = await portal.swap( swapsApiKey: 'YOUR_SWAPS_API_KEY', chainId: 'eip155:8453', // Base sellToken: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', // ETH buyToken: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC amount: '1000000000000000', // 0.001 ETH in wei ); if (result.success) { print('Swap successful: ${result.transactionHash}'); } ``` ### Native Token Address Use the following address for native tokens (ETH, MATIC, etc.): ``` 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE ``` ## Errors | Code | Description | | ---------------------- | ---------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `SWAP_FAILED` | The swap operation failed | | `INSUFFICIENT_BALANCE` | Not enough tokens to swap | | `INVALID_API_KEY` | The swaps API key is invalid | ## Related * [Perform swaps guide](../guide/perform-swaps) # yieldIntegrations.yieldxyz.discover Source: https://docs.portalhq.io/sdks/flutter/reference/yield-discover Discover yield opportunities. ## Function Signature ```dart theme={null} Future discover({ YieldXyzGetYieldsRequest? request, }) ``` ## Description Search for available yield opportunities across different networks and protocols. ## Parameters | Parameter | Type | Required | Description | | --------- | --------------------------- | -------- | ------------------------------- | | `request` | `YieldXyzGetYieldsRequest?` | No | Optional filters for the search | **`YieldXyzGetYieldsRequest`** fields: | Field | Type | Description | | ------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------- | | `offset` | `int?` | Pagination offset | | `limit` | `int?` | Maximum number of results | | `network` | `String?` | Filter by blockchain network | | `yieldId` | `String?` | Filter by specific yield opportunity ID | | `type` | `YieldXyzMechanicsType?` | Filter by mechanics type (`staking`, `restaking`, `lending`, `vault`, `fixedYield`, `realWorldAsset`) | | `hasCooldownPeriod` | `bool?` | Filter opportunities with cooldown | | `hasWarmupPeriod` | `bool?` | Filter opportunities with warmup | | `token` | `String?` | Filter by token symbol | | `inputToken` | `String?` | Filter by input token | | `provider` | `String?` | Filter by yield provider | | `search` | `String?` | Text search across opportunities | | `sort` | `YieldXyzGetYieldsSort?` | Sort order (`statusEnterAsc`, `statusEnterDesc`, `statusExitAsc`, `statusExitDesc`) | ## Returns **`YieldXyzGetYieldsResponse`** - An object containing: | Property | Type | Description | | -------- | ------------------------- | --------------------------------------- | | `items` | `List` | List of yield opportunities | | `total` | `int` | Total number of available opportunities | | `offset` | `int` | Current pagination offset | | `limit` | `int` | Current page size | ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final response = await portal.yieldIntegrations.yieldxyz.discover( request: YieldXyzGetYieldsRequest( network: 'ethereum', token: 'USDC', type: YieldXyzMechanicsType.lending, limit: 10, ), ); for (final opportunity in response.items) { print('Yield: ${opportunity?.id}'); } ``` ## Errors | Code | Description | | ----------------- | -------------------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `YIELD_FAILED` | Failed to discover yield opportunities | ## Related * [Earn with Yield.xyz guide](../guide/yield-xyz) * [yieldIntegrations.yieldxyz.enter](./yield-enter) * [yieldIntegrations.yieldxyz.getBalances](./yield-getbalances) # yieldIntegrations.yieldxyz.enter Source: https://docs.portalhq.io/sdks/flutter/reference/yield-enter Enter a yield position. ## Function Signature ```dart theme={null} Future enter({ required String yieldId, String? address, String? amount, String? arguments, }) ``` ## Description Deposit assets into a yield-generating opportunity. ## Parameters | Parameter | Type | Required | Description | | ----------- | --------- | -------- | ----------------------------------------------------- | | `yieldId` | `String` | Yes | The ID of the yield opportunity to enter | | `address` | `String?` | No | Wallet address (defaults to current wallet) | | `amount` | `String?` | No | Amount to deposit | | `arguments` | `String?` | No | Additional protocol-specific arguments as JSON string | ## Returns **`YieldXyzActionResponse`** - An object containing: | Property | Type | Description | | ------------------ | ---------------------------------- | ---------------------------------------------------------------------------------------------------- | | `id` | `String` | Action ID for tracking | | `intent` | `YieldXyzActionIntent` | Action intent (`enter`, `exit`, `manage`) | | `type` | `String` | Action type | | `yieldId` | `String` | The yield opportunity ID | | `address` | `String` | Wallet address used | | `amount` | `String?` | Deposit amount | | `amountRaw` | `String?` | Raw deposit amount | | `amountUsd` | `String?` | USD equivalent amount | | `transactions` | `List` | List of transactions to be signed and broadcast | | `executionPattern` | `YieldXyzActionExecutionPattern` | Execution pattern (`synchronous`, `asynchronous`, `batched`) | | `createdAt` | `String` | Creation timestamp | | `completedAt` | `String?` | Completion timestamp | | `status` | `YieldXyzActionStatus` | Current status (`created`, `processing`, `success`, `failed`, `canceled`, `waitingForNext`, `stale`) | ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final response = await portal.yieldIntegrations.yieldxyz.enter( yieldId: 'aave-v3-usdc-ethereum', amount: '100.0', ); print('Action ID: ${response.id}'); print('Transactions to sign: ${response.transactions.length}'); ``` ## Errors | Code | Description | | ----------------- | ------------------------------ | | `NOT_INITIALIZED` | Portal was not initialized | | `YIELD_FAILED` | Failed to enter yield position | ## Related * [Earn with Yield.xyz guide](../guide/yield-xyz) * [yieldIntegrations.yieldxyz.discover](./yield-discover) * [yieldIntegrations.yieldxyz.exit](./yield-exit) * [yieldIntegrations.yieldxyz.track](./yield-track) # yieldIntegrations.yieldxyz.exit Source: https://docs.portalhq.io/sdks/flutter/reference/yield-exit Exit a yield position. ## Function Signature ```dart theme={null} Future exit({ required String yieldId, String? address, String? amount, String? arguments, }) ``` ## Description Withdraw assets from a yield opportunity. ## Parameters | Parameter | Type | Required | Description | | ----------- | --------- | -------- | ----------------------------------------------------- | | `yieldId` | `String` | Yes | The ID of the yield opportunity to exit | | `address` | `String?` | No | Wallet address (defaults to current wallet) | | `amount` | `String?` | No | Amount to withdraw (null for full withdrawal) | | `arguments` | `String?` | No | Additional protocol-specific arguments as JSON string | ## Returns **`YieldXyzActionResponse`** - An object containing: | Property | Type | Description | | ------------------ | ---------------------------------- | ---------------------------------------------------------------------------------------------------- | | `id` | `String` | Action ID for tracking | | `intent` | `YieldXyzActionIntent` | Action intent (`enter`, `exit`, `manage`) | | `type` | `String` | Action type | | `yieldId` | `String` | The yield opportunity ID | | `address` | `String` | Wallet address used | | `amount` | `String?` | Withdrawal amount | | `amountRaw` | `String?` | Raw withdrawal amount | | `amountUsd` | `String?` | USD equivalent amount | | `transactions` | `List` | List of transactions to be signed and broadcast | | `executionPattern` | `YieldXyzActionExecutionPattern` | Execution pattern (`synchronous`, `asynchronous`, `batched`) | | `createdAt` | `String` | Creation timestamp | | `completedAt` | `String?` | Completion timestamp | | `status` | `YieldXyzActionStatus` | Current status (`created`, `processing`, `success`, `failed`, `canceled`, `waitingForNext`, `stale`) | ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final response = await portal.yieldIntegrations.yieldxyz.exit( yieldId: 'aave-v3-usdc-ethereum', amount: '50.0', ); print('Action ID: ${response.id}'); ``` ## Errors | Code | Description | | ----------------- | ----------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `YIELD_FAILED` | Failed to exit yield position | ## Related * [Earn with Yield.xyz guide](../guide/yield-xyz) * [yieldIntegrations.yieldxyz.enter](./yield-enter) * [yieldIntegrations.yieldxyz.getBalances](./yield-getbalances) * [yieldIntegrations.yieldxyz.track](./yield-track) # yieldIntegrations.yieldxyz.getBalances Source: https://docs.portalhq.io/sdks/flutter/reference/yield-getbalances Get yield balances for wallet addresses. ## Function Signature ```dart theme={null} Future getBalances({ required List queries, }) ``` ## Description Retrieve current balances across different yield positions for one or more wallet addresses. ## Parameters | Parameter | Type | Required | Description | | --------- | -------------------------------- | -------- | ----------------------- | | `queries` | `List` | Yes | List of balance queries | **`YieldXyzGetBalancesQuery`** fields: | Field | Type | Description | | --------- | --------- | -------------------------------------- | | `address` | `String` | Wallet address to check | | `network` | `String` | Blockchain network | | `yieldId` | `String?` | Optional specific yield opportunity ID | ## Returns **`YieldXyzGetBalancesResponse`** - An object containing: | Property | Type | Description | | ---------- | ------------------------------ | --------------------------------------------- | | `items` | `List` | List of yield balances by yield opportunity | | `errors` | `List?` | Any errors encountered during balance queries | | `metadata` | `YieldXyzGetBalancesMetadata?` | Response metadata | | `error` | `String?` | Top-level error message, if any | ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final response = await portal.yieldIntegrations.yieldxyz.getBalances( queries: [ YieldXyzGetBalancesQuery( address: '0x1234...', network: 'ethereum', ), ], ); for (final balance in response.items) { if (balance != null) { print('Balance: ${balance.amount}'); } } ``` ## Errors | Code | Description | | ----------------- | ---------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `YIELD_FAILED` | Failed to get yield balances | ## Related * [Earn with Yield.xyz guide](../guide/yield-xyz) * [yieldIntegrations.yieldxyz.manage](./yield-manage) * [yieldIntegrations.yieldxyz.getHistoricalActions](./yield-gethistoricalactions) # yieldIntegrations.yieldxyz.getHistoricalActions Source: https://docs.portalhq.io/sdks/flutter/reference/yield-gethistoricalactions Get historical yield actions. ## Function Signature ```dart theme={null} Future getHistoricalActions({ int? offset, int? limit, String? address, YieldXyzActionStatus? status, YieldXyzActionIntent? intent, String? type, String? yieldId, }) ``` ## Description Retrieve past yield actions including entries, exits, and management operations. ## Parameters | Parameter | Type | Required | Description | | --------- | ----------------------- | -------- | --------------------------------------------- | | `offset` | `int?` | No | Pagination offset | | `limit` | `int?` | No | Maximum number of results | | `address` | `String?` | No | Filter by wallet address | | `status` | `YieldXyzActionStatus?` | No | Filter by action status | | `intent` | `YieldXyzActionIntent?` | No | Filter by action intent (enter, exit, manage) | | `type` | `String?` | No | Filter by action type | | `yieldId` | `String?` | No | Filter by yield opportunity ID | ## Returns **`YieldXyzGetHistoricalActionsResponse`** - An object containing: | Property | Type | Description | | ---------- | ------------------------------------- | -------------------------- | | `items` | `List` | List of historical actions | | `total` | `int` | Total number of actions | | `offset` | `int` | Current pagination offset | | `limit` | `int` | Current page size | | `metadata` | `YieldXyzGetBalancesMetadata?` | Response metadata | | `error` | `String?` | Error message, if any | ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final response = await portal.yieldIntegrations.yieldxyz.getHistoricalActions( address: '0x1234...', intent: YieldXyzActionIntent.enter, limit: 20, ); for (final action in response.items) { if (action != null) { print('Action: ${action.id} - ${action.status}'); } } ``` ## Errors | Code | Description | | ----------------- | -------------------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `YIELD_FAILED` | Failed to get historical yield actions | ## Related * [Earn with Yield.xyz guide](../guide/yield-xyz) * [yieldIntegrations.yieldxyz.getBalances](./yield-getbalances) * [yieldIntegrations.yieldxyz.getTransaction](./yield-gettransaction) # yieldIntegrations.yieldxyz.getTransaction Source: https://docs.portalhq.io/sdks/flutter/reference/yield-gettransaction Get details for a yield transaction. ## Function Signature ```dart theme={null} Future getTransaction( String transactionId, ) ``` ## Description Retrieve full details about a specific yield transaction, including its status, hash, and metadata. ## Parameters | Parameter | Type | Required | Description | | --------------- | -------- | -------- | ------------------------------------- | | `transactionId` | `String` | Yes | The ID of the transaction to retrieve | ## Returns **`YieldXyzGetTransactionResponse`** - An object containing: | Property | Type | Description | | ------------- | --------------------------------- | ------------------------------------------------------------- | | `transaction` | `YieldXyzActionTransaction` | Full transaction details including status, hash, and metadata | | `metadata` | `YieldXyzGetTransactionMetadata?` | Response metadata | | `error` | `String?` | Error message, if any | ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final response = await portal.yieldIntegrations.yieldxyz.getTransaction( 'transaction-id-123', ); print('Status: ${response.transaction.status}'); print('Hash: ${response.transaction.hash}'); ``` ## Errors | Code | Description | | ----------------- | ------------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `YIELD_FAILED` | Failed to get yield transaction | ## Related * [Earn with Yield.xyz guide](../guide/yield-xyz) * [yieldIntegrations.yieldxyz.track](./yield-track) * [yieldIntegrations.yieldxyz.getHistoricalActions](./yield-gethistoricalactions) # yieldIntegrations.yieldxyz.manage Source: https://docs.portalhq.io/sdks/flutter/reference/yield-manage Manage a yield position. ## Function Signature ```dart theme={null} Future manage({ required String yieldId, required String action, String? address, String? arguments, String? passthrough, }) ``` ## Description Perform management actions on a yield position, such as claiming rewards or compounding. Requires a `passthrough` value obtained from a balance's `pendingAction`. ## Parameters | Parameter | Type | Required | Description | | ------------- | --------- | -------- | --------------------------------------------------------------------------------- | | `yieldId` | `String` | Yes | The ID of the yield opportunity to manage | | `action` | `String` | Yes | The management action to perform (e.g., `"claim"`, `"compound"`) | | `address` | `String?` | No | Wallet address (defaults to current wallet) | | `arguments` | `String?` | No | Additional protocol-specific arguments as JSON string | | `passthrough` | `String` | Yes | Data from balance `pendingAction`. Throws `MISSING_PASSTHROUGH` if null or empty. | ## Returns **`YieldXyzActionResponse`** - An object containing: | Property | Type | Description | | ------------------ | ---------------------------------- | ---------------------------------------------------------------------------------------------------- | | `id` | `String` | Action ID for tracking | | `intent` | `YieldXyzActionIntent` | Action intent (`enter`, `exit`, `manage`) | | `type` | `String` | Action type | | `yieldId` | `String` | The yield opportunity ID | | `address` | `String` | Wallet address used | | `amount` | `String?` | Amount | | `amountRaw` | `String?` | Raw amount | | `amountUsd` | `String?` | USD equivalent amount | | `transactions` | `List` | List of transactions to be signed and broadcast | | `executionPattern` | `YieldXyzActionExecutionPattern` | Execution pattern (`synchronous`, `asynchronous`, `batched`) | | `createdAt` | `String` | Creation timestamp | | `completedAt` | `String?` | Completion timestamp | | `status` | `YieldXyzActionStatus` | Current status (`created`, `processing`, `success`, `failed`, `canceled`, `waitingForNext`, `stale`) | ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); // First get balances to find pending actions final balances = await portal.yieldIntegrations.yieldxyz.getBalances( queries: [YieldXyzGetBalancesQuery(address: '0x...', network: 'ethereum')], ); // Use the passthrough from a pending action final response = await portal.yieldIntegrations.yieldxyz.manage( yieldId: 'aave-v3-usdc-ethereum', action: 'claim', passthrough: balances.items.first.pendingAction?.passthrough, ); ``` ## Errors | Code | Description | | --------------------- | ------------------------------------------------------------------------ | | `NOT_INITIALIZED` | Portal was not initialized | | `MISSING_PASSTHROUGH` | Passthrough is required for manage. Get it from balance `pendingAction`. | | `YIELD_FAILED` | Failed to manage yield position | ## Related * [Earn with Yield.xyz guide](../guide/yield-xyz) * [yieldIntegrations.yieldxyz.getBalances](./yield-getbalances) * [yieldIntegrations.yieldxyz.track](./yield-track) # yieldIntegrations.yieldxyz.track Source: https://docs.portalhq.io/sdks/flutter/reference/yield-track Track a yield transaction on-chain. ## Function Signature ```dart theme={null} Future track( String transactionId, String txHash, ) ``` ## Description Submit a blockchain transaction hash to update the status of a yield action. Use this after broadcasting a transaction to link the on-chain hash with the yield action. ## Parameters | Parameter | Type | Required | Description | | --------------- | -------- | -------- | ---------------------------------- | | `transactionId` | `String` | Yes | The yield transaction ID to update | | `txHash` | `String` | Yes | The blockchain transaction hash | ## Returns **`YieldXyzTrackTransactionResponse`** - An object containing: | Property | Type | Description | | --------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `success` | `bool` | Whether the tracking was successful | | `transactionId` | `String` | The tracked transaction ID | | `status` | `YieldXyzActionTransactionStatus?` | Updated transaction status (`notFound`, `created`, `blocked`, `waitingForSignature`, `signed`, `broadcasted`, `pending`, `confirmed`, `failed`, `skipped`) | | `transaction` | `YieldXyzActionTransaction?` | Full transaction details | ## Example ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); final response = await portal.yieldIntegrations.yieldxyz.track( 'transaction-id-123', '0xabcdef1234567890...', ); print('Tracked: ${response.success}'); print('Status: ${response.status}'); ``` ## Errors | Code | Description | | ----------------- | --------------------------------- | | `NOT_INITIALIZED` | Portal was not initialized | | `YIELD_FAILED` | Failed to track yield transaction | ## Related * [Earn with Yield.xyz guide](../guide/yield-xyz) * [yieldIntegrations.yieldxyz.getTransaction](./yield-gettransaction) * [yieldIntegrations.yieldxyz.enter](./yield-enter) * [yieldIntegrations.yieldxyz.exit](./yield-exit) # Add custom signature hooks Source: https://docs.portalhq.io/sdks/ios/guide/add-custom-signature-hooks This guide will help you configure custom signature hooks for requests to the Portal Provider to ensure your business logic is properly implemented into Portal's signing flow. ## What are custom signature hooks? **Custom signature hooks** allow companies to trigger approval business logic based on parameters including transaction value, transaction volume, types of signature, and more. The business logic can be anything that helps the company and/or end user make good decisions during the signing request process. A couple examples are auto-approvals (eg, auto-approving transactions below a certain value or volume) and two-factor authentication (eg, requiring approval for transactions above a certain limit). Another option are security checks, either internal or external, to prevent malicious activity. ## Why use custom signature hooks? There are a few common reasons developers use custom signature hooks. For example: * **Approving transactions** - Most developers will prompt a user for approval before signing a message from a dApp or Wallet Connect * **Adding additional authentication** - Developers can add extra authentication steps such as MFA to riskier transactions * **Checking transaction sizes** - Developers can perform checks on transaction sizes to trigger specific authentication steps Custom signature hooks can be passive, invisible to the user, or active, designed into the frontend UX with which customers engage. A **passive** flow will generally be used to facilitate company goals, such as ensuring a user isn’t on a deny list. As such, the company can create a custom security check that is activated when a customer signs a transaction without them knowing. Transactions will continue as normal unless the user is found to be denied use of the app, at which time it will be rejected. An **active** flow is visible to the user and can be used to offer a superior user experience. For example, companies can trigger MFA for transactions above a certain size, an example we will dive into at the end of this guide. ## How do custom signature hooks work? The Portal approval flow is driven by a series of event hooks that take place throughout the lifecycle of a Provider requests. These hooks are as follows: * `portal_signingRequested` - Emitted by the Provider when a new signing request is received by the Provider * `portal_signingApproved` - Emitted by you when signing a given request is approved * `portal_signingRejected` - Emitted by you when signing a given request is rejected ## Designing your flow The expectation of this flow is that your code binds to the `portal_signingRequested` event using the following pattern: ```swift theme={null} portal.on(event: Events.PortalSigningRequested, callback: { [weak portal] data in // portal_signingRequested // On approval portal?.emit(Events.PortalSigningApproved, data) // portal_signingApproved // On rejection portal?.emit(Events.PortalSigningRejected, data) // portal_signingRejected }) ``` As demonstrated by this example, you can implement any business logic you feel is required to make good decisions about what signing requests to process. You can prompt your user for approval, you can make requests to your backend to run some code remotely, etc. The only restriction of this flow is that when you have determined whether a signing request should be approved or rejected, you should emit either the `portal_signingRejected` or `portal_signingApproved` event. *NOTE: When emitting these events, Portal uses the `method` and `params` to determine if we are indeed approving or rejecting the correct request, so it is vital that you pass the request data to your `emit()` calls.* ### A more real-world example: MFA for large transactions A common use case is to trigger additional approval from users for large transactions. The implementation of this using custom signature hooks might look something like this: ```swift theme={null} portal.on(event: Events.PortalSigningRequested, callback: { [weak portal] data in // portal_signingRequested // Check the transaction size var isLargeTransaction = checkIfLargeTransaction(data) // Request MFA approval from user if large transaction var isMfaApproved = false if (isLargeTransaction) { isMfaApproved = getMfaApproval() } // Approve transaction request if MFA was successful if (isMfaApproved) { portal?.emit(Events.PortalSigningApproved, data) // portal_signingApproved } else { portal?.emit(Events.PortalSigningRejected, data) // portal_signingRejected } }) ``` The outcome would look like something like this: ![](https://lh4.googleusercontent.com/IUz10Pt88VpjlIwlhswdWv9Uns9Aui_adLXQGFOufIitcLENyWELQcwAjR-uhoFIT5EsneS0ZW7vJ8khMr_2Vtn-2reeudbwmRMsTmRRW3S7yeMVI1UkoAZ5zcWSdKn9hCSrYB0271KH9hlLIB3ePfc) # Back up a wallet Source: https://docs.portalhq.io/sdks/ios/guide/back-up-a-wallet This guide will walk you through how to create a backup of a Portal client's wallet. ## Portal-Managed Backups Portal lets you securely back up your users' MPC wallets so they can recover their wallets even if their device is lost or damaged. By default, Portal encrypts and stores both backup shares ("Portal-Managed Backups"): 1. The **client backup share** is encrypted on the user's device, with the encryption key stored using their chosen backup method (Google Drive, iCloud, Password, Passkey, or Firebase Auth). The encrypted share is then stored by Portal. 2. The **custodian backup share** is encrypted and stored by Portal, with the encryption key stored in our KMS infrastructure. By default, Portal manages storing both the encrypted client backup share and the custodian backup share for you. If you prefer to store and manage the backup shares in your own infrastructure instead of using Portal-Managed Backups, see our [Self-Managed Backups](../../../resources/self-managed-backups) guide. Both the client backup share and the custodian backup share are necessary to recover a Portal wallet. ## Backup Methods You can choose one or more backup methods for storing the encryption key for the client backup share. ### Passkey + Enclave Allow customers to create a native passkey on their device that is used to authenticate into a secure enclave that holds the encryption key for the user. Customer's passkeys are backed up to the native cloud storage for their device. #### Implementation Requirements 1. Initialize passkey storage as a backup option in the Portal Config Object. 2. Configuring the relying party #### Use Portal as your relying party 1. Add `portalhq.io` as a web credential domain in your app. 2. Share your app bundle id with the Portal Team. #### Use your own domain as the relying party Ensure you have set up your associate domain correctly in your app and that you are serving an aasa file from whatever your relying party domain is set to. You will need to be sure you have the `webcredential` field set properly for your app in your aasa file. Resources from apple: * [Configuring an associated domain](https://developer.apple.com/documentation/xcode/configuring-an-associated-domain#Provide-an-Apple-App-Site-Association-file) * [Supporting associated domains](https://developer.apple.com/documentation/xcode/supporting-associated-domains#Add-the-associated-domain-file-to-your-website) #### Relying party A relying party is a trusted domain that is tied to the public key credentials of your users for their passkey . We offer the option to use `portalhq.io` as your relying party domain. It requires you to add `portalhq.io` as an Associated Domain in your iOS application and share your team id + application bundle id. If you already have your domain as a `webcredential` for your application then you can simply pass in your domain as the relying party and everything should work. ```swift theme={null} // Run backup. let ( encryptedClientBackupShare, storageCallback ) = try await portal.backupWallet(.Passkey) { status in // (Optional) Create a progress indicator here in the progress callback. } ``` ```swift theme={null} // Your API URL let yourApiUrl: String = "https://YOUR_API_URL.com" // Run backup. let ( encryptedClientBackupShare, storageCallback ) = try await portal.backupWallet(.Passkey) { status in // (Optional) Create a progress indicator here in the progress callback. } // Obtain your API's URL for storing the encrypted user backup share. guard let url = URL(string: "\(yourApiUrl)/users/\(userId)/store-encrypted-user-backup-share") else { throw URLError(.badURL) } // Store the encrypted user backup share on your API. let requests = PortalRequests() try await requests.post( url, andPayload: [ "backupMethod": withMethod.rawValue, "encryptedClientBackupShare": encryptedClientBackupShare, ] ) // Call the storageCallback to notify Portal you stored the user backup share successfully. try await storageCallback() // ✅ The user has now backed up with their passkey successfully ``` ### Password/PIN Allow customers to create a password/pin. Customers can either remember the password or store it in a password storage manager. #### Implementation Requirements 1. Create a UI for password input. 2. Enforce password requirements. Customer can choose between password, PIN code, passcode, or any other text-based input. 3. If user forgets password there are no additional recovery options. ```swift theme={null} // Set the user's password. try portal.setPassword("THE-USER-PASSWORD") // Run backup. let ( encryptedClientBackupShare, storageCallback ) = try await portal.backupWallet(.Password) { status in // (Optional) Create a progress indicator here in the progress callback. } ``` ```swift theme={null} // Your API URL let yourApiUrl: String = "https://YOUR_API_URL.com" // Set the user's password. try portal.setPassword("THE-USER-PASSWORD") // Run backup. let ( encryptedClientBackupShare, storageCallback ) = try await portal.backupWallet(.Password) { status in // (Optional) Create a progress indicator here in the progress callback. } // Obtain your API's URL for storing the encrypted user backup share. guard let url = URL(string: "\(yourApiUrl)/users/\(userId)/store-encrypted-user-backup-share") else { throw URLError(.badURL) } // Store the encrypted user backup share on your API. let requests = PortalRequests() try await requests.post( url, andPayload: [ "backupMethod": withMethod.rawValue, "encryptedClientBackupShare": encryptedClientBackupShare, ] ) // Call the storageCallback to notify Portal you stored the user backup share successfully. try await storageCallback() // ✅ The user has now backed up with their password successfully ``` ### Firebase Auth Backup Allow customers to use their existing Firebase Authentication to authenticate into a secure enclave that holds the encryption key for the user. The Portal SDK leverages Firebase ID tokens to securely store and retrieve encryption keys from the secure enclave. This is ideal if your app already uses Firebase Auth — no additional authentication method is required from your users. See the [Firebase Auth Backup setup guide](../../../resources/backup-options/firebase-byo-auth) for prerequisites and Firebase project configuration. #### Configure Firebase storage ```swift theme={null} import FirebaseAuth import PortalSwift // Register the Firebase backup method portal.registerBackupMethod(.Firebase, withStorage: FirebaseStorage( getToken: { guard let user = Auth.auth().currentUser else { return nil } return try await user.getIDToken(forcingRefresh: true) } )) ``` The user must be signed in to Firebase before performing any backup or recovery operations. If no Firebase user is signed in, the `getToken` callback returns `nil` and the operation will fail. ```swift theme={null} // Ensure user is signed in to Firebase, then run backup. let ( encryptedClientBackupShare, storageCallback ) = try await portal.backupWallet(.Firebase) { status in // (Optional) Create a progress indicator here in the progress callback. } // Complete the backup try await storageCallback() ``` ```swift theme={null} // Your API URL let yourApiUrl: String = "https://YOUR_API_URL.com" // Ensure user is signed in to Firebase, then run backup. let ( encryptedClientBackupShare, storageCallback ) = try await portal.backupWallet(.Firebase) { status in // (Optional) Create a progress indicator here in the progress callback. } // Obtain your API's URL for storing the encrypted user backup share. guard let url = URL(string: "\(yourApiUrl)/users/\(userId)/store-encrypted-user-backup-share") else { throw URLError(.badURL) } // Store the encrypted user backup share on your API. let requests = PortalRequests() try await requests.post( url, andPayload: [ "backupMethod": BackupMethods.Firebase.rawValue, "encryptedClientBackupShare": encryptedClientBackupShare, ] ) // Call the storageCallback to notify Portal you stored the user backup share successfully. try await storageCallback() ``` ### iCloud See the docs on how to configure [iCloud](../../../resources/backup-options/icloud). For the `iCloud` action handling: ```swift theme={null} // Run backup. let ( encryptedClientBackupShare, storageCallback ) = try await portal.backupWallet(.iCloud) { status in // (Optional) Create a progress indicator here in the progress callback. } ``` ```swift theme={null} // Your API URL let yourApiUrl: String = "https://YOUR_API_URL.com" // Set the user's password. try portal.setPassword("THE-USER-PASSWORD") // Run backup. let ( encryptedClientBackupShare, storageCallback ) = try await portal.backupWallet(.iCloud) { status in // (Optional) Create a progress indicator here in the progress callback. } // Obtain your API's URL for storing the encrypted user backup share. guard let url = URL(string: "\(yourApiUrl)/users/\(userId)/store-encrypted-user-backup-share") else { throw URLError(.badURL) } // Store the encrypted user backup share on your API. let requests = PortalRequests() try await requests.post( url, andPayload: [ "backupMethod": withMethod.rawValue, "encryptedClientBackupShare": encryptedClientBackupShare, ] ) // Call the storageCallback to notify Portal you stored the user backup share successfully. try await storageCallback() // ✅ The user has now backed up with their iCloud successfully ``` ### Google Drive See the docs on how to configure [Google Drive](../../../resources/backup-options/gdrive). After initializing Portal, you will need to configure the following: ```swift theme={null} // Set GDrive Configuration try portal.setGDriveConfiguration( clientId: "your-google-client-id", backupOption: .appDataFolder ) // Set the GDrive presenting view try portal.setGDriveView(self) ``` `setGDriveConfiguration` function parameters: * **clientId**: The client ID for the Google Drive integration. * **backupOption**: An option from the `GDriveBackupOption` enum that specifies the backup storage type: * `appDataFolder`: Stores backups in the hidden, app-specific "App Data Folder" in Google Drive. This folder is not visible to the user. * `appDataFolderWithFallback`: Attempts to store backups and recover using the "App Data Folder". If recover fails, it automatically falls back to a user-visible Google Drive. * `gdriveFolder(folderName: String)`: Stores backups in a user-visible folder in Google Drive with the specified `folderName`. `setGDriveView` function parameters: * **view**: A `UIViewController` instance that will be used to present Google Drive UI components. For the `GoogleDrive` action handling: ```swift theme={null} // Run backup. let ( encryptedClientBackupShare, storageCallback ) = try await portal.backupWallet(.GoogleDrive) { status in // (Optional) Create a progress indicator here in the progress callback. } ``` ```swift theme={null} // Your API URL let yourApiUrl: String = "https://YOUR_API_URL.com" // Set the user's password. try portal.setPassword("THE-USER-PASSWORD") // Run backup. let ( encryptedClientBackupShare, storageCallback ) = try await portal.backupWallet(.GoogleDrive) { status in // (Optional) Create a progress indicator here in the progress callback. } // Obtain your API's URL for storing the encrypted user backup share. guard let url = URL(string: "\(yourApiUrl)/users/\(userId)/store-encrypted-user-backup-share") else { throw URLError(.badURL) } // Store the encrypted user backup share on your API. let requests = PortalRequests() try await requests.post( url, andPayload: [ "backupMethod": withMethod.rawValue, "encryptedClientBackupShare": encryptedClientBackupShare, ] ) // Call the storageCallback to notify Portal you stored the user backup share successfully. try await storageCallback() // ✅ The user has now backed up with their Google Drive successfully ``` #### App Data Folder vs. GDrive Files * **App Data Folder**: A hidden, app-specific storage area that is not visible to the user in their Google Drive interface. This is ideal for sensitive data or configurations that the user doesn't need to manage directly. * **Google Drive Files**: A visible folder in the user's Google Drive, accessible and manageable by the user. This is suitable for backups the user might want to view, share, or organize manually. The App Data Folder feature is supported starting from SDK version 4.2.0. If you enable this feature, ensure that you do not use any SDK version older than 4.2.0, as it will result in the loss of App Data Folder backups. **Related Documentation** * [Backup options](../../../resources/backup-options/gdrive) * [Firebase Auth Backup option](../../../resources/backup-options/firebase-byo-auth) * [registerBackupMethod function reference](../reference/registerbackupmethod) * [FirebaseStorage reference](../reference/firebasestorage) * [setGDriveConfiguration function reference](../reference/setgdriveconfiguration) * [setPasskeyConfiguration function reference](../reference/setpasskeyconfiguration) * [setPasskeyAuthenticationAnchor function reference](../reference/setpasskeyauthenticationanchor) * [setPassword function reference](../reference/setpassword) * [isWalletBackedUp function reference](../reference/iswalletbackedup) # Enhance your wallets' security with Blockaid Source: https://docs.portalhq.io/sdks/ios/guide/blockaid Learn how to integrate real-time transaction, address, token, and URL security scanning using Portal's iOS SDK with Blockaid. Portal's iOS SDK integrates with Blockaid to provide real-time security scanning for transactions, addresses, tokens, and URLs. This integration helps detect malicious activity, phishing attempts, scam tokens, and suspicious interactions before users sign or submit transactions. ## Overview The Blockaid integration enables you to: * **Scan transactions** before signing or submission (EVM, Solana) * **Scan addresses** for known malicious actors or compromised contracts * **Validate tokens** to detect scams or security risks * **Verify URLs** to prevent phishing attacks ## Prerequisites Before using Blockaid security scanning, ensure you have: * A properly initialized Portal client * Blockaid integration enabled in your Portal Dashboard ## Scanning EVM Transactions Use `scanEVMTx` to analyze Ethereum transactions before signing or broadcasting them. This method scans EVM transactions for malicious contract interactions, risky approvals, phishing attempts, and other on-chain security threats. ```swift theme={null} Task { do { let transactionData = BlockaidScanEVMTransactionData( from: "0x7C01728004d3F2370C1BBC36a4Ad680fE6FE8729", to: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", data: "0x095ea7b3...", value: "0x0", gas: "0x2dc6c0", gasPrice: "0x2dc6c0", nonce: "0x924" ) let request = BlockaidScanEVMRequest( chain: "eip155:1", data: transactionData, options: [.simulation, .validation] ) let response = try await portal.security.blockaid.scanEVMTx(request: request) if let rawResponse = response.data?.rawResponse { print("Chain: \(rawResponse.chain)") print("Block: \(rawResponse.block)") if let validation = rawResponse.validation { print("Validation Status: \(validation.status)") print("Result Type: \(validation.resultType)") if validation.resultType == "Malicious" { print("⚠️ Transaction flagged as MALICIOUS") } else { print("✅ Transaction appears safe") } } } } catch { print("Error scanning EVM transaction: \(error)") } } ``` *** ## Scanning Solana Transactions Use `scanSolanaTx` to analyze Solana transactions before signing. This method detects malicious program invocations, suspicious token movements, and other Solana-specific risks. ```swift theme={null} Task { do { let transactions = [ "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQADCQkVR3SiiKbW0l4c3NBsEn6+zn1o0YsyypPwN0GUhg4K5HK0Tb5GckDLYW+MsovQASt5EZ3bSH3nluRJAE69H61w0BRUDTrpYQcXosUun6/z2BROkRoH/1bL7KLU9s4lCav6k3ZZgV6qeZFwu4pu89WoIGaqUxG4C93XwVmmDy81v8qBaCSP4/UZfdo3q1bud/W+ixymkH8IMe0laQZYrSx4Uhyxec67hYm1VqLV7JTSSYaC/fm7KvWtZOSRzEFT2gMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAT4tlY/P4mFG1wDJl0ektVggHiZf73lTlHBVJ3fK0nDoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANG5fPtlMEOI/eXV7aPDlpcdLUKm8L3VoW6k/oJlCNLaBQYABQLARQQABgAJAwYAAAAAAAAABzwACQoLCwgyMzQMNQ0ONjcPEDg5EgETFBUWOhEXGBkaGxwdHh8gISI7IyQlJicCKCkqKywtAwQuLzAxPBFVCg8JAQcHBgYBAAAAAwHwCgYBExUbBgICAAAPAwIAAAYBISMoEQQBGQAPAwIAAAYBLjA2DwMCAAAGAgIAAAAIBgYICAADAQkGCQUFBgACBQAEBwEAAAgCAAUMAgAAADwaAAAAAAAABgAFBGDMBQAEPPm21Wu6wrmHu23/ZFNIumpp+ADooZjd4JQgvjnBxkUJAgEDBqWqCgmmCAUIBwu1tp+gcP/+Ri3C1tRXUbPdgqo6rVsj/qnqC959wTdC/mRARysLz9HS09TW19jZ2tsC1QYsNrdxMcm5Nq5FXZrM0IXpEA+ApFa+pz/JvkLz0+2vnwuztLW2t7i5uru8vgAPvBv8VUeRwDy9yD1NHIH5Ji6ZA+zrmpHejKOz4MP8SwrKy8zNzs/S09TVAdY=" ] let request = BlockaidScanSolanaRequest( accountAddress: "86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY", transactions: transactions, encoding: .base64, chain: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", options: [.simulation, .validation], method: "signAndSendTransaction" ) let response = try await portal.security.blockaid.scanSolanaTx(request: request) if let rawResponse = response.data?.rawResponse { print("Status: \(rawResponse.status ?? "Unknown")") if let result = rawResponse.result, let validation = result.validation { print("Result Type: \(validation.resultType)") if validation.resultType == "Malicious" { print("⚠️ Transaction flagged as MALICIOUS") } else { print("✅ Transaction appears safe") } } } } catch { print("Error scanning Solana transaction: \(error)") } } ``` *** ## Scanning Addresses Use `scanAddress` to analyze a single address for known security risks. This method can be used for both EVM and Solana addresses and detects malicious contracts, compromised wallets, sanctioned addresses, and other flagged entities. ### EVM Address Scan ```swift theme={null} Task { do { let request = BlockaidScanAddressRequest( address: "0x31c05d73f2333b5a176cfdbb7c5ef96ec7bb04ac", chain: "eip155:1" ) let response = try await portal.security.blockaid.scanAddress(request: request) if let rawResponse = response.data?.rawResponse { print("Result Type: \(rawResponse.resultType)") if let features = rawResponse.features { print("Features: \(features.count)") for feature in features { print(" - [\(feature.type)] \(feature.featureId): \(feature.description)") } } } } catch { print("Error scanning EVM address: \(error)") } } ``` ### Solana Address Scan ```swift theme={null} Task { do { let request = BlockaidScanAddressRequest( address: "BBQUMqaFELxTFh8r1xSttYMHX6ZKzLWhsmGod2vuxgEc", chain: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" ) let response = try await portal.security.blockaid.scanAddress(request: request) if let rawResponse = response.data?.rawResponse { print("Result Type: \(rawResponse.resultType)") } } catch { print("Error scanning Solana address: \(error)") } } ``` *** ## Scanning Tokens Use `scanTokens` to analyze multiple tokens in a single request for known security risks. This method detects scam tokens, honeypots, compromised contracts, and other malicious token behavior. ```swift theme={null} Task { do { let request = BlockaidScanTokensRequest( chain: "eip155:1", tokens: [ "0x66587563e933bbf3974b89156b47bb82b921eb35", "0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d" ] ) let response = try await portal.security.blockaid.scanTokens(request: request) if let rawResponse = response.data?.rawResponse { print("Results count: \(rawResponse.results.count)") for (address, result) in rawResponse.results { print("Token: \(address)") print(" Result Type: \(result.resultType)") if let score = result.maliciousScore { print(" Malicious Score: \(score)") } if let metadata = result.metadata { print(" Name: \(metadata.name ?? "Unknown")") print(" Symbol: \(metadata.symbol ?? "Unknown")") } if result.resultType == "Malicious" { print(" ⚠️ Token is MALICIOUS") } } } } catch { print("Error scanning tokens: \(error)") } } ``` *** ## Scanning URLs Use `scanURL` to detect phishing sites and malicious domains before users navigate to them. This method helps protect users from social engineering attacks and malicious off-chain activity. ```swift theme={null} Task { do { let request = BlockaidScanURLRequest( url: "https://ethlen.com" ) let response = try await portal.security.blockaid.scanURL(request: request) if let rawResponse = response.data?.rawResponse { print("Status: \(rawResponse.status)") if rawResponse.status == "hit" { if let isMalicious = rawResponse.isMalicious, isMalicious { print("⚠️ URL flagged as MALICIOUS") } else { print("✅ URL appears safe") } print("Malicious Score: \(rawResponse.maliciousScore ?? 0)") print("Is Web3 Site: \(rawResponse.isWeb3Site ?? false)") } else if rawResponse.status == "miss" { print("ℹ️ URL not in database (miss)") } } } catch { print("Error scanning URL: \(error)") } } ``` *** ## Error Handling All Blockaid scan methods are async throwing functions. If a request fails, the function will throw an exception. You should wrap all scan calls in a do-catch block to handle potential errors. The following errors may be thrown: | Error | Description | | ----------------------------------------- | ------------------------------------------------------------------------------- | | `PortalRequestsError.unauthorized` | Authentication failed (401). Verify your Portal client is properly initialized. | | `PortalRequestsError.clientError` | Client error (4xx). The request was invalid or malformed. | | `PortalRequestsError.internalServerError` | Server error (5xx). A server-side issue occurred. | | `PortalRequestsError.redirectError` | Redirect error (3xx). An unexpected redirect occurred. | ```swift theme={null} do { let response = try await portal.security.blockaid.scanEVMTx(request: request) // Handle successful response } catch PortalRequestsError.unauthorized { print("Authentication failed - check your Portal configuration") } catch PortalRequestsError.clientError(let message, let url) { print("Client error: \(message) at \(url)") } catch PortalRequestsError.internalServerError(let message, let url) { print("Server error: \(message) at \(url)") } catch { print("Unexpected error: \(error)") } ``` *** ## Next Steps * Learn about [signing transactions](./sign-a-transaction) * Explore [transaction evaluation](./evaluate-a-transaction) * Review [Portal API methods](./portal-api-methods) * Check out the [Blockaid Integration setup](../../../integrations/Security/blockaid) # Build a WebView Source: https://docs.portalhq.io/sdks/ios/guide/build-a-webview This guide will walk you through how to build a WebView to interact with dApps using the Portal wallet. Once you have a Portal instance and you have generated a wallet, you can now build a web view to interact with dApps using the Portal wallet. In the example below, we created `WebViewController` that initializes Portal's `PortalWebView` and adds it as a child view controller. `PortalWebView` init parameters: * **portal**: Your Portal instance. * **url**: The URL the web view should start at. * **persistSessionData**: Will persist browser session data (local-storage, cookies, etc...) when enabled. * **onError**: An error handler in case the web view throws errors. * **onPageStart**: A handler that fires when the web view is starting to load a page. * **onPageComplete**: A handler that fires when the web view has finished loading a page. * **eip6963Icon**: A string representing the Base64-encoded icon for EIP-6963 compliance. * **eip6963Name**: A string representing the name for EIP-6963 compliance. * **eip6963Rdns**: A reverse DNS string for identifying the application in EIP-6963-compliant contexts. * **eip6963Uuid**: A unique identifier string for EIP-6963 compliance. ```swift theme={null} import PortalSwift import WebKit class WebViewController: UIViewController, PortalWebViewDelegate { var portal: Portal? var url: String? var webViewController: PortalWebView? let persistSessionData = false let eip6963Icon = "Base64-encoded image string" let eip6963Name = "Portal MPC Wallet" let eip6963Rdns = "io.portalhq" let eip6963Uuid = "d73d7104-7e24-442b-913b-1147cd8e0325" override func viewDidLoad() { super.viewDidLoad() guard let portal else { print("❌ WebViewController error: The portal object is nil.") return } guard let url else { print("❌ WebViewController error: The url object is nil.") return } guard let url = URL(string: url) else { print("❌ WebViewController error: URL could not be derived.") return } webViewController = PortalWebView( portal: portal, // Your Portal instance. url: url, // The URL the web view should start at. persistSessionData: persistSessionData, // Will persist browser session data (local-storage, cookies, etc...) when enabled. onError: self.onErrorHandler, // An error handler in case the web view throws errors. onPageStart: self.onPageStartHandler, // A handler that fires when the web view is starting to load a page. onPageComplete: self.onPageCompleteHandler, // A handler that fires when the web view has finished loading a page. eip6963Icon: eip6963Icon, // A string representing the Base64-encoded icon for EIP-6963 compliance. eip6963Name: eip6963Name, // A string representing the name for EIP-6963 compliance. eip6963Rdns: eip6963Rdns, // A reverse DNS string for identifying the application in EIP-6963-compliant contexts. eip6963Uuid: eip6963Uuid // A unique identifier string for EIP-6963 compliance. ) guard let webViewController = webViewController else { print("❌ WebViewController error: the PortalWebView object is nil.") return } webViewController.delegate = self // Install the WebViewController as a child view controller. addChild(webViewController) guard let webViewControllerView = webViewController.view else { print("❌ WebViewController error: webViewController.view could not be derived.") return } view.addSubview(webViewControllerView) webViewController.didMove(toParent: self) } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) self.webViewController = nil } func onPageStartHandler() { print("🔄 PortalWebView: Page loading started") // Add Loader here if needed } func onPageCompleteHandler() { print("✅ PortalWebView: Page loading completed") // Remove the loader here if needed } func onErrorHandler(result: Result) { if let error = result.error { print("❌ PortalWebviewError:", error, "Description:", error.localizedDescription) return } guard let dataAsAnyObject = result.data as? AnyObject, let nestedResult = dataAsAnyObject.result as? Result else { print("❌ Unable to cast result data") return } if let nestedError = nestedResult.error { print("❌ Error in nested PortalWebviewError:", nestedError) return } } func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { print("✅ Delegate method fired!", webView, navigationAction, decisionHandler) decisionHandler(.allow) } } ``` And thats it! Remember that you will need a portal instance to start the web view and you will also need to initialize Portal with `autoApprove: true` if you want the web view to auto-approve transactions. Next, let's explore how to use Portal's Swaps integration to perform swaps directly from your iOS app. # Configure log level Source: https://docs.portalhq.io/sdks/ios/guide/configure-log-level Control the verbosity of SDK log output at runtime using PortalLogLevel. By default, the Portal SDK emits no logs. You can enable logging at any verbosity level to help debug integration issues or monitor SDK behavior in development. ## Log levels The `PortalLogLevel` enum defines five levels. Each level includes all levels above it in severity. | Level | Value | What is logged | | -------- | ----- | --------------------------------------------------------------------------------------- | | `.none` | 0 | Nothing. This is the default. | | `.error` | 1 | Failures only — failed transactions, network errors, binary crashes. | | `.warn` | 2 | Unexpected but non-fatal conditions — deprecated usage, retries, slow responses. | | `.info` | 3 | Normal operational milestones — signing started, share generated, connection opened. | | `.debug` | 4 | Everything, including internals — request/response payloads, timing, state transitions. | ## Set the log level Call `portal.setLogLevel(_:)` after initializing your `Portal` instance. The change takes effect immediately across all SDK components. ```swift theme={null} import PortalSwift let portal = try Portal( "YOUR_CLIENT_API_KEY", withRpcConfig: ["eip155:1": "https://mainnet.infura.io/v3/YOUR_INFURA_KEY"] ) // Enable debug logging during development portal.setLogLevel(.debug) ``` Set the log level before calling any other SDK methods to capture all output from the start. ## Recommended levels by environment * **Development**: `.debug` — see all SDK activity while building your integration. * **QA / staging**: `.info` or `.warn` — surface operational milestones and anomalies without noise. * **Production**: `.none` (default) — no logs emitted. Use `.error` if you want to forward failures to a crash reporter. Do not use `.debug` in production. Debug output includes request payloads and internal state that may contain sensitive data. ## Log output Logs are written using `os_log` to the `io.portalhq.ios` subsystem under the `General` category. You can filter them in the Console app or Xcode's debug console using: ``` subsystem:io.portalhq.ios ``` **Related Documentation** * [setLogLevel function reference](../reference/setloglevel) # Connect with WalletConnect Source: https://docs.portalhq.io/sdks/ios/guide/connect-with-walletconnect Portal Connect allows you to connect your Portal MPC Wallet to any dApp that supports WalletConnect without extra dependency bloat. Portal Connect utilizes your existing `Portal` instance to connect your Portal MPC Wallet to dApps via WalletConnect. When connecting via Portal Connect, a few things happen in the background: 1. You provide a WalletConnect `uri` for Portal Connect to connect with 2. Portal Connect opens a WebSocket connection to the WalletConnect `uri` 3. All requests received over the WebSocket are routed to `portal.provider` to perform the request like normal This workflow allows for seamless integration between your Portal MPC Wallet and countless dApps that already include support for WalletConnect. ## Installing The `PortalConnect` class is including in the `PortalSwift` pod, so no additional steps are required to start using `PortalConnect` in your app. ## Sharing the Portal instance with your ConnectViewController In the ViewController that segues to your `ConnectViewController`, you'll need to set the `Portal` instance on the `destination` `ViewController`. ```kotlin theme={null} override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if let connectViewController = segue.destination as? ConnectViewController { connectViewController.portal = self.portal } } ``` ## Initializing PortalConnect To initialize Portal Connect, call `portal.createPortalConnectInstance()`. ```swift theme={null} import PortalSwift class ConnectViewController: UIViewController { private var connect: PortalConnect? public var portal: Portal? // UI Elements @IBOutlet weak var connectButton: UIButton! @IBOutlet weak var addressTextInput: UITextField! override func viewDidLoad() { super.viewDidLoad() connectButton.isEnabled = false connect = portal.createPortalConnectInstance() } ``` ## Connecting via WalletConnect To connect to a WalletConnect URI, using the mechanism that makes the most sense for your app, capture the WalletConnect URI. The most common practice is to use a QR Code scanner for this. Once you've captured the URI, you can call the `connect(uri)` function on your `PortalConnect` instance. This will initialize the WebSocket session with the WalletConnect relay and begin passing messages to your Portal Provider using your Portal MPC Wallet. ```kotlin theme={null} @IBAction func connectPressed() { let uri = addressTextInput.text connect?.connect(uri!) } ``` Listen for the `connect` and `disconnect` events to get confirmation that you have successfully connected or if the user disconnects. ```swift theme={null} connect.on(event: Events.Connect.rawValue) { (data: Any) in } ``` ```swift theme={null} connect.on(event: Events.Disconnect.rawValue) { (data: Any) in } ``` Once this is done, you've got a connection to WalletConnect. All messages sent by the relay will now be processed by the Portal Provider. Wallet Connect's [Auth API](https://docs.walletconnect.com/web3wallet/wallet-usage#auth-requests) is not currently supported. We have it on our roadmap. Please reach out if this is an urgent feature request. ## Handling Session Requests `Session Requests` represent the initial connection request from the dApp to create a new session. These are triggered when Portal responds to the dApp after the `connect(uri)` function is called. These events will be triggered with either a `SessionProposal` object. These objects can be used to display information about the dApp the user is connecting to and the specific permissions being requested by the dApp. ### Accepting the dApps proposal We have a helper method `addChainsToProposal` . This can be used to add all the chains in your gateway config to the proposal object. ### Binding to Session Requests In order to bind to Session Requests, add an event handler to your `PortalConnect` instance. ```swift theme={null} connect.on("portal_dappSessionRequested") { data in // ... your business logic if (approved) { if var connectData = data as? ConnectData { var newConnectData = portalConnect.addChainsToProposal(data: connectData) portalConnect.emit(event: Events.PortalDappSessionApproved.rawValue, data: newConnectData) } else { print("Invalid data type. Expected ConnectData.") } } else { connect.emit("portal_dappSessionRejected", data) } } ``` ### Retrieve Session Request Portal stores session requests for up to 24 hours. This feature can be useful if PortalConnect loses connection with the dApp being used and you want to respond to previous session requests. To retrieve a previous session request, use the `emitGetSessionRequest` method. You will need to provide both the `requestId` and the `topic`. ```swift theme={null} portalConnect.emitGetSessionRequest( requestId: "requestId", topic: "topic" ) ``` **Important Notes:** * You can only respond to a session request **once**. * Ensure you have stored the correct **`requestId`** and **`topic`** values to successfully retrieve the session request. This functionality helps maintain continuity in dApp interactions by allowing you to handle any outstanding session requests even after a connection disruption. ## Handle Signing Each portal connect instance gets created with its own instance of the Portal Provider. This allows users to connect with different chains to different dApps. #### Listen for signing events Handle signing approval. This is only required if `autoApprove` is turned off. ```swift theme={null} portalConnect.on(event: Events.PortalSigningRequested.rawValue) { (data: Any) in } ``` Listen for confirmation of signing requests ```swift theme={null} portalConnect.on(event: Events.PortalSignatureReceived.rawValue) { (data: Any) in } ``` ## Handle Warnings and Errors Set up a listener for `portal_connectError` in order to handle specific errors and warnings from Portal Connect. Check out our Portal Connect Error Codes [here](../../../resources/error-codes). ## Switching Chains In order to switch the active chain for a portal connect instance use the `setChainId` method. You must include that `chainId` in the gateway config with a gateway url on initialization of your Portal Object, otherwise, you can not switch chains. ```kotlin theme={null} connect.setChainId(137) // switches the active chain to polygon mainnet ``` ## The final ConnectViewController class Once you've gone through that process, you should have a file that looks something like this. ```swift theme={null} import PortalSwift class ConnectViewController: UIViewController { private var connect: PortalConnect? public var portal: Portal? // UI Elements @IBOutlet weak var connectButton: UIButton! @IBOutlet weak var addressTextInput: UITextField! override func viewDidLoad() { super.viewDidLoad() connectButton.isEnabled = false connect = PortalConnect(portal!) } @IBAction func connectPressed() { let uri = addressTextInput.text connect?.connect(uri!) } connect.on("portal_dappSessionRequested") { data in // ... your business logic if (approved) { connect.emit("portal_dappSessionApproved", data) } else { connect.emit("portal_dappSessionRejected", data) } } @IBAction func uriChanged(_ sender: Any) { let uri = addressTextInput.text connectButton.isEnabled = uri != nil && uri?.isEmpty == false && uri?.starts(with: "wc:") == true } } ``` # Create a wallet Source: https://docs.portalhq.io/sdks/ios/guide/create-a-wallet Once you have a Portal instance, you can now create a wallet for your user. If you don't have a `Portal` instance already, we recommend reading [this page](./) first. In the MPC wallet creation process, two sets of key shares are generated. The **signing key shares** are used for signing transactions, and the **backup key shares** are used for recovery if the device storing a signing key share is lost. The **`portal.createWallet`** command initiates the MPC process to create a set of shares. The users' signing shares are automatically stored in the user's secure phone storage. Ensure you have created and connected the UI element in the storyboard to the action. ```swift theme={null} // Create the wallet. // Obtain the Ethereum and Solana addresses of the user's new wallet. let addresses = try await portal.createWallet() print("My Portal EVM address: \(addresses.ethereum)") print("My Portal Solana address: \(addresses.solana)") ``` Want faster wallet creation? Enable the [`usePreGeneratedWallet`](./feature-flags#2-usepregeneratedwallet) feature flag—no changes to the code above are needed. Be sure to use a **Production** API key when creating clients for production. Read more details about going to production [here](../../../resources/going-to-production). **WARNING**: To create a wallet with the Portal SDK, your device must be configured to use passcode authentication. If you change your passcode, your Portal wallet will continue to operate as expected. However, if you disable passcode authentication after running the `generate` function, you will be required to execute the `recover` function before you can continue using your Portal wallet. ### Status Flow ```swift theme={null} case generatingShare = "Generating share" case parsingShare = "Parsing share" case storingShare = "Storing share" case done = "Done" ``` Now that you've created your user's wallet, let's try to send a transaction with it! **Related Documentation** * [CreateWallet function reference](../reference/createwallet) # Cross-device sessions Source: https://docs.portalhq.io/sdks/ios/guide/cross-device-sessions Use wallet recovery to enable your users to create multiple signing shares across their devices. Once users successfully back up their wallets, they can generate a new set of signing shares irrespective of the SDK in use. Furthermore, both the new and old sets of signing shares can be used simultaneously, thanks to **`portal.recoverWallet`**. This provisioning relies on the user already having a successful set of backup shares (in the below example we'll assume they are password backup shares). While recovering, the wallet fetches the encryption key from the user's cloud storage provider to decrypt the encrypted user backup share. Note that our SDK handles the decryption; you only need to supply the encrypted user backup share that was stored by your API. For wallet recovery support, consult the [recovery documentation](./recover-a-wallet) to establish the necessary **`/backup/fetch`** webhook. Also, ensure the user has successfully completed the backup process. Here's how to implement **`portal.recoverWallet`**: ```swift theme={null} // The CipherText decoding object struct CipherTextResult: Codable { var cipherText: String } // Your API URL let yourApiUrl = "https://YOUR_API_URL.com" // The user id let userId = "THE-USER-ID" // Set the user's password. // Important note: this is only needed for the password BackupMethod try portal.setPassword("THE-USER-PASSWORD") // Obtain your API's URL for retrieving the encrypted user backup share. // TODO: - replace the `BackupMethods.Password.rawValue` with the proper backup method you are using guard let url = URL(string: "\(yourApiUrl)/users/\(userId)/encrypted-user-backup-shares?backupMethod=\(BackupMethods.Password.rawValue)") else { throw URLError(.badURL) } // Retrieve the encrypted user backup share on your API. let requests = PortalRequests() let yourApiResponse = try await requests.get(url) let decodedResponse = try decoder.decode(CipherTextResult.self, from: yourApiResponse) let encryptedUserBackupShare = decodedResponse.cipherText // TODO: - replace the `.Password` with the proper backup method you are using try await portal?.recoverWallet(.Password, withCipherText: encryptedUserBackupShare) { _ in // (Optional) Create a progress indicator here in the progress callback. } // ✅ The user has now recovered with their password successfully! ``` And that's it! You're now equipped to support multiple sessions across different devices for your users. # Manage Token Delegations Source: https://docs.portalhq.io/sdks/ios/guide/delegations Learn how to approve, revoke, and manage token delegations using Portal's iOS SDK. Portal's iOS SDK provides token delegation capabilities through the `portal.delegations` API. This enables approving token spending, revoking approvals, checking delegation status, and transferring tokens as a delegate on both EVM and Solana chains. ## Overview The delegations functionality allows you to: * **Approve** other addresses to spend tokens on behalf of your wallet * **Revoke** existing delegations to remove spending permissions * **Check status** of active delegations and balances * **Transfer tokens** as a delegate from another address ## Prerequisites Before using delegation operations, ensure you have: * A properly initialized Portal client * An active wallet with tokens on the target network (see [Create a wallet](./create-a-wallet)) * Understanding of [token delegations concepts](/resources/delegations) Delegations apply to ERC-20 tokens (EVM) and SPL Tokens (Solana) only. Native assets like ETH, MON, and SOL cannot be delegated — they have no on-chain `approve` / `transferFrom` (or SPL delegate) semantics. Calls using a native asset identifier will be rejected. See [Delegations](/resources/delegations#what-are-token-delegations) for the protocol-level reason and workarounds. ## High-Level Methods Use `approveAndSubmit`, `revokeAndSubmit`, and `transferAndSubmit` when you want one call for the whole flow: build the delegation transaction(s), then sign and broadcast each one in order and collect the resulting hashes. `Portal` installs a working signer on `portal.delegations` for you, so the common case needs no configuration at all. `approveAndSubmit`, `revokeAndSubmit`, and `transferAndSubmit` broadcast each transaction and return as soon as it is accepted by the network. They **do not wait for on-chain confirmation**. A hash in `DelegationSubmitResult.hashes` means the transaction was submitted, not that it succeeded — an approval can still revert. If your flow depends on the delegation being active, wait for the receipt yourself, or poll `getStatus` before proceeding. This is different from [Yield.xyz](./yield-xyz), where you wait for each transaction to confirm before moving on to the next step. Do not carry that assumption over to delegations. ### Signatures ```swift theme={null} func setSignAndSendTransaction(_ fn: @escaping DelegationSignAndSend) func approveAndSubmit( request: ApproveDelegationRequest, options: DelegationSubmitOptions ) async throws -> DelegationSubmitResult func revokeAndSubmit( request: RevokeDelegationRequest, options: DelegationSubmitOptions ) async throws -> DelegationSubmitResult func transferAndSubmit( request: TransferFromRequest, options: DelegationSubmitOptions ) async throws -> DelegationSubmitResult ``` Each submit method also has a no-options convenience overload, which is what most callers want since `Portal` has already installed a signer: ```swift theme={null} func approveAndSubmit(request: ApproveDelegationRequest) async throws -> DelegationSubmitResult func revokeAndSubmit(request: RevokeDelegationRequest) async throws -> DelegationSubmitResult func transferAndSubmit(request: TransferFromRequest) async throws -> DelegationSubmitResult ``` The request types are the same ones the low-level methods take — see [EVM Approval](#evm-approval), [EVM Revoke](#evm-revoke), and [EVM Transfer From](#evm-transfer-from) below for their fields. ### Configuring the signer A signer signs and broadcasts one transaction and returns its hash: ```swift theme={null} public typealias DelegationSignAndSend = ( _ transaction: DelegationTransaction, _ chainId: String ) async throws -> String ``` `DelegationTransaction` covers both ecosystems: ```swift theme={null} public enum DelegationTransaction: Equatable { case evm(ConstructedEipTransaction) case solana(String) // base64-encoded public var evmTransaction: ConstructedEipTransaction? { get } public var solanaTransaction: String? { get } } ``` `Portal` installs a default signer that routes `.evm` transactions to `eth_sendTransaction` and `.solana` transactions to `sol_signAndSendTransaction`, so this works with zero setup: ```swift theme={null} let result = try await portal.delegations.approveAndSubmit(request: request) ``` Use `setSignAndSendTransaction(_:)` to replace the signer for the instance, or `DelegationSubmitOptions.signAndSendTransaction` to override it for a single call. The precedence is **per-call option → instance signer → `Portal` default**. ### Options and progress `DelegationSubmitOptions` is the second argument to each submit method: | Parameter | Type | Required | Description | | ------------------------ | --------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------- | | `signAndSendTransaction` | `DelegationSignAndSend?` | No | Per-call signer override. Takes priority over the instance signer set with `setSignAndSendTransaction(_:)`. | | `onProgress` | `((DelegationSubmitProgress) -> Void)?` | No | Called as each transaction is signed and submitted. | `DelegationSubmitProgress` carries: | Field | Type | Description | | ------- | ---------------------- | ------------------------------------------------------------------------ | | `step` | `DelegationSubmitStep` | `.signing` or `.submitted`. | | `index` | `Int` | The 0-based index of this transaction in the sequence. | | `total` | `Int` | The total number of transactions in the sequence. | | `hash` | `String?` | `nil` on `.signing`, and the broadcast transaction hash on `.submitted`. | `DelegationSubmitStep` has exactly two cases — `signing` and `submitted`. There is no confirming or confirmed step, because nothing is awaited on-chain. ### Return value `DelegationSubmitResult` carries only the hashes: | Field | Type | Description | | -------- | ---------- | -------------------------------------------------------- | | `hashes` | `[String]` | One hash per broadcast transaction, in submission order. | There is no status field, and no partial-success concept: a hash is present because the network accepted the transaction, and nothing beyond that has been checked. ### Example (approve and submit) This builds the same approval as [EVM Approval](#evm-approval) below, but signs and broadcasts it for you. ```swift theme={null} import PortalSwift Task { do { let request = ApproveDelegationRequest( chain: "eip155:11155111", token: "USDC", delegateAddress: "0xa944e86eb36f039becd1843132347eb5b8501562", amount: "0.01" ) let result = try await portal.delegations.approveAndSubmit( request: request, options: DelegationSubmitOptions( onProgress: { progress in switch progress.step { case .signing: print("Signing \(progress.index + 1)/\(progress.total)") case .submitted: print("Submitted: \(progress.hash ?? "")") } } ) ) // These are broadcast, not confirmed. print("Submitted hashes: \(result.hashes)") } catch DelegationsError.noTransactions { print("The approval response contained no transactions to submit.") } catch DelegationsError.invalidTransactionHash(let index, let chainId) { print("Signer returned an unusable hash for transaction \(index) on \(chainId)") } catch { print("Error approving and submitting delegation: \(error)") } } ``` The `switch` over `progress.step` is exhaustive with no `default:` — there really are only two steps. ### Example (revoke and submit) Same `RevokeDelegationRequest` as [EVM Revoke](#evm-revoke) below, submitted end to end. ```swift theme={null} import PortalSwift Task { do { let request = RevokeDelegationRequest( chain: "eip155:11155111", token: "USDC", delegateAddress: "0xa944e86eb36f039becd1843132347eb5b8501562" ) let result = try await portal.delegations.revokeAndSubmit(request: request) print("Revoke submitted: \(result.hashes)") } catch DelegationsError.noTransactions { print("The revoke response contained no transactions to submit.") } catch { print("Error revoking delegation: \(error)") } } ``` Because the revoke is not confirmed when this call returns, the delegation may still be active for a short time afterwards. Poll `getStatus` if you need to show the user that it is gone. ### Example (transfer as a delegate) Same `TransferFromRequest` as [EVM Transfer From](#evm-transfer-from) below. Your wallet must already be an approved delegate for `fromAddress`. ```swift theme={null} import PortalSwift Task { do { let request = TransferFromRequest( chain: "eip155:11155111", token: "USDC", fromAddress: "0x099699ed181517d4ce0ba4487bea671d31bb1db5", // Token owner toAddress: "0xdFd8302f44727A6348F702fF7B594f127dE3A902", // Recipient amount: "0.01" ) let result = try await portal.delegations.transferAndSubmit(request: request) print("Transfer submitted: \(result.hashes)") } catch DelegationsError.noTransactions { print("The transfer response contained no transactions to submit.") } catch { print("Error transferring delegated tokens: \(error)") } } ``` ### Example (custom signer) Replace the default signer when you need to do something it does not, such as attaching a `signatureApprovalMemo` to every delegation transaction. `evmTransaction` and `solanaTransaction` each return `nil` for the other case, so you can branch without a full `switch`: ```swift theme={null} import PortalSwift enum MyAppError: Error { case missingTransactionHash case unsupportedTransaction } func sendEvmDelegationTransaction( _ transaction: ConstructedEipTransaction, chainId: String ) async throws -> String { // `ConstructedEipTransaction` is `Codable`, so you can also encode it here for logging // or to hand it off to another process before signing. var params: [String: String] = [ "from": transaction.from, "to": transaction.to ] if let data = transaction.data { params["data"] = data } if let value = transaction.value { params["value"] = value } let response = try await portal.request( chainId: chainId, method: .eth_sendTransaction, params: [params], options: RequestOptions(signatureApprovalMemo: "Delegation transaction") ) guard let hash = response.result as? String else { throw MyAppError.missingTransactionHash } return hash } func sendSolanaDelegationTransaction( _ encodedTransaction: String, chainId: String ) async throws -> String { let response = try await portal.request( chainId: chainId, method: .sol_signAndSendTransaction, params: [encodedTransaction], options: RequestOptions(signatureApprovalMemo: "Delegation transaction") ) guard let hash = response.result as? String else { throw MyAppError.missingTransactionHash } return hash } // Branch with the accessors portal.delegations.setSignAndSendTransaction { transaction, chainId in if let evmTransaction = transaction.evmTransaction { return try await sendEvmDelegationTransaction(evmTransaction, chainId: chainId) } if let encodedTransaction = transaction.solanaTransaction { return try await sendSolanaDelegationTransaction(encodedTransaction, chainId: chainId) } throw MyAppError.unsupportedTransaction } // Or match the enum, when you want the compiler to make you handle both cases portal.delegations.setSignAndSendTransaction { transaction, chainId in switch transaction { case let .evm(evmTransaction): return try await sendEvmDelegationTransaction(evmTransaction, chainId: chainId) case let .solana(encodedTransaction): return try await sendSolanaDelegationTransaction(encodedTransaction, chainId: chainId) } } ``` To use a different signer for a single call, pass it in the options instead — it takes priority over the instance signer: ```swift theme={null} let result = try await portal.delegations.approveAndSubmit( request: request, options: DelegationSubmitOptions( signAndSendTransaction: { transaction, chainId in guard let evmTransaction = transaction.evmTransaction else { throw MyAppError.unsupportedTransaction } return try await sendEvmDelegationTransaction(evmTransaction, chainId: chainId) } ) ) ``` ### Errors The submit methods throw `DelegationsError` in addition to the network and decoding errors the low-level methods can throw. | Case | When | | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `noSignerConfigured` | No signer was available. This cannot happen on `portal.delegations`, which `Portal` wires for you — only when you construct `Delegations` yourself. | | `noTransactions` | The delegation response contained no transactions to submit. | | `invalidTransactionHash(index:chainId:)` | The signer returned a value that is not a usable hash for that chain. `index` identifies which transaction in the sequence it was. | `DelegationsProtocol` gained `setSignAndSendTransaction`, `approveAndSubmit`, `revokeAndSubmit`, and `transferAndSubmit` in 7.3.0. Calling code is unaffected, but anything that *implements* the protocol — a hand-rolled test mock, for example — needs all four before it will compile. *** ## Low-level methods The sections below are the manual path: `approve`, `revoke`, `transferFrom`, and `getStatus` return unsigned transactions and leave signing and broadcasting to you. Use them when you need to inspect, modify, batch, or route the transactions yourself. Otherwise prefer the high-level methods above. ## Approving Delegations Use `approve` to grant another address permission to spend tokens on your behalf. This method works for both EVM and Solana chains. ### EVM Approval ```swift theme={null} Task { do { let request = ApproveDelegationRequest( chain: "eip155:11155111", token: "USDC", delegateAddress: "0xa944e86eb36f039becd1843132347eb5b8501562", amount: "0.01" ) let response = try await portal.delegations.approve(request: request) // Sign and send EVM transactions sequentially if let transactions = response.transactions { for (index, tx) in transactions.enumerated() { var txDict: [String: String] = [ "from": tx.from, "to": tx.to ] if let data = tx.data { txDict["data"] = data } if let value = tx.value { txDict["value"] = value } let txResponse = try await portal.request( chainId: "eip155:11155111", method: .eth_sendTransaction, params: [txDict], options: RequestOptions(signatureApprovalMemo: "Approve delegation") ) print("Tx \(index + 1) hash: \(txResponse.result as? String ?? "Unknown")") } } } catch { print("Error approving EVM delegation: \(error)") } } ``` ### Solana Approval ```swift theme={null} Task { do { let request = ApproveDelegationRequest( chain: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", token: "USDC", delegateAddress: "7smgSuU5mjP7QY5yWGdaTfgKn8hUWwvQgfvgcZB3HmJi", amount: "0.01" ) let response = try await portal.delegations.approve(request: request) // Sign and send Solana transactions sequentially if let encodedTransactions = response.encodedTransactions { for (index, encodedTx) in encodedTransactions.enumerated() { let txResponse = try await portal.request( chainId: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", method: .sol_signAndSendTransaction, params: [encodedTx], options: RequestOptions(signatureApprovalMemo: "Approve delegation") ) print("Tx \(index + 1) hash: \(txResponse.result as? String ?? "Unknown")") } } } catch { print("Error approving Solana delegation: \(error)") } } ``` *** ## Checking Delegation Status Use `getStatus` to check current delegations and token balances for a specific delegate address. ### EVM Status Check ```swift theme={null} Task { do { let request = GetDelegationStatusRequest( chain: "eip155:11155111", token: "USDC", delegateAddress: "0xa944e86eb36f039becd1843132347eb5b8501562" ) let response = try await portal.delegations.getStatus(request: request) print("Chain ID: \(response.chainId)") print("Token: \(response.token)") print("Token Address: \(response.tokenAddress)") if let balance = response.balance { print("Balance: \(balance)") } print("Delegations: \(response.delegations.count)") for delegation in response.delegations { print(" - Address: \(delegation.address), Amount: \(delegation.delegateAmount)") } } catch { print("Error getting EVM delegation status: \(error)") } } ``` ### Solana Status Check ```swift theme={null} Task { do { let request = GetDelegationStatusRequest( chain: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", token: "USDC", delegateAddress: "7smgSuU5mjP7QY5yWGdaTfgKn8hUWwvQgfvgcZB3HmJi" ) let response = try await portal.delegations.getStatus(request: request) print("Chain ID: \(response.chainId)") print("Token: \(response.token)") print("Delegations: \(response.delegations.count)") for delegation in response.delegations { print(" - Address: \(delegation.address), Amount: \(delegation.delegateAmount)") } } catch { print("Error getting Solana delegation status: \(error)") } } ``` *** ## Revoking Delegations Use `revoke` to remove spending permissions from a delegate address. ### EVM Revoke ```swift theme={null} Task { do { let request = RevokeDelegationRequest( chain: "eip155:11155111", token: "USDC", delegateAddress: "0xa944e86eb36f039becd1843132347eb5b8501562" ) let response = try await portal.delegations.revoke(request: request) // Sign and send EVM transactions sequentially if let transactions = response.transactions { for (index, tx) in transactions.enumerated() { var txDict: [String: String] = [ "from": tx.from, "to": tx.to ] if let data = tx.data { txDict["data"] = data } if let value = tx.value { txDict["value"] = value } let txResponse = try await portal.request( chainId: "eip155:11155111", method: .eth_sendTransaction, params: [txDict], options: RequestOptions(signatureApprovalMemo: "Revoke delegation") ) print("Tx \(index + 1) hash: \(txResponse.result as? String ?? "Unknown")") } } } catch { print("Error revoking EVM delegation: \(error)") } } ``` ### Solana Revoke ```swift theme={null} Task { do { let request = RevokeDelegationRequest( chain: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", token: "USDC", delegateAddress: "7smgSuU5mjP7QY5yWGdaTfgKn8hUWwvQgfvgcZB3HmJi" ) let response = try await portal.delegations.revoke(request: request) // Sign and send Solana transactions sequentially if let encodedTransactions = response.encodedTransactions { for (index, encodedTx) in encodedTransactions.enumerated() { let txResponse = try await portal.request( chainId: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", method: .sol_signAndSendTransaction, params: [encodedTx], options: RequestOptions(signatureApprovalMemo: "Revoke delegation") ) print("Tx \(index + 1) hash: \(txResponse.result as? String ?? "Unknown")") } } } catch { print("Error revoking Solana delegation: \(error)") } } ``` Always revoke unused delegations after completing operations to minimize security risks. *** ## Transferring as a Delegate Use `transferFrom` to transfer tokens from another address that has delegated spending permission to you. ### EVM Transfer From ```swift theme={null} Task { do { let request = TransferFromRequest( chain: "eip155:11155111", token: "USDC", fromAddress: "0x099699ed181517d4ce0ba4487bea671d31bb1db5", // Token owner toAddress: "0xdFd8302f44727A6348F702fF7B594f127dE3A902", // Recipient amount: "0.01" ) let response = try await portal.delegations.transferFrom(request: request) // Sign and send EVM transactions sequentially if let transactions = response.transactions { for (index, tx) in transactions.enumerated() { var txDict: [String: String] = [ "from": tx.from, "to": tx.to ] if let data = tx.data { txDict["data"] = data } if let value = tx.value { txDict["value"] = value } let txResponse = try await portal.request( chainId: "eip155:11155111", method: .eth_sendTransaction, params: [txDict], options: RequestOptions(signatureApprovalMemo: "Transfer delegated tokens") ) print("Tx \(index + 1) hash: \(txResponse.result as? String ?? "Unknown")") } } } catch { print("Error transferring EVM delegation: \(error)") } } ``` ### Solana Transfer From ```swift theme={null} Task { do { let request = TransferFromRequest( chain: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", token: "USDC", fromAddress: "ARttPLesu9RiX6H111Pfdc9Y2DhGy1B8P8jyyrD8Cj5b", // Token owner toAddress: "GPsPXxoQA51aTJJkNHtFDFYui5hN5UxcFPnheJEHa5Du", // Recipient amount: "0.01" ) let response = try await portal.delegations.transferFrom(request: request) // Sign and send Solana transactions sequentially if let encodedTransactions = response.encodedTransactions { for (index, encodedTx) in encodedTransactions.enumerated() { let txResponse = try await portal.request( chainId: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", method: .sol_signAndSendTransaction, params: [encodedTx], options: RequestOptions(signatureApprovalMemo: "Transfer delegated tokens") ) print("Tx \(index + 1) hash: \(txResponse.result as? String ?? "Unknown")") } } } catch { print("Error transferring Solana delegation: \(error)") } } ``` **Delegation Roles**: `fromAddress` is the token owner who approved the delegation. Your wallet (the delegate) signs the transaction to transfer tokens from the owner to the `toAddress` recipient. *** ## Supported Networks Delegations work on all Portal-supported EVM and Solana chains: * **EVM**: Ethereum, Polygon, Base, Arbitrum, Optimism, Monad, and all other EVM-compatible chains * **Solana**: Solana Mainnet and Devnet For a complete list, see [Blockchain Support](/resources/blockchain-support). *** ## Next Steps * Read the reference pages for [approveAndSubmit](../reference/delegationsapproveandsubmit), [revokeAndSubmit](../reference/delegationsrevokeandsubmit), [transferAndSubmit](../reference/delegationstransferandsubmit), and [setSignAndSendTransaction](../reference/delegationssetsignandsendtransaction) * Learn about [signing transactions](./sign-a-transaction) * Explore [Portal API methods](./portal-api-methods) * Review [delegation concepts](/resources/delegations) * Check out [wallet lifecycle management](./manage-wallet-lifecycle-states) # Eject a wallet Source: https://docs.portalhq.io/sdks/ios/guide/eject-a-wallet The eject feature allows a user to construct private keys that can be imported into another wallet manager, such as MetaMask. **Warning:** Providing the custodian backup share to the client device puts both MPC shares on a single device, removing the multi-party security benefits of MPC. This operation should only be done for users who want to move off of MPC and into a single private key. **Use `portal.eject()` at your own risk!** ### Ejecting Methods You can eject wallets using either Portal-Managed Backups or Self-Managed Backups depending on your implementation. ### Portal-managed backups To eject the private keys for your users' wallets, their two matching backup shares need to be combined. Since both of these shares are stored on the Portal backend encrypted at rest, we first need to verify that the upcoming request to eject the wallet is not an attack. In order to do this, we make this a two-step process: **Implementation Requirements** 1. Make a Custodian-authed `POST` request using your Custodian API Key (the same API key you use to create new users) to the `/api/v3/custodians/me/clients/\(clientId)/prepare-eject` endpoint to allow for this client to perform an `eject` operation 2. Call the `portal.eject()` or `portal.ejectPrivateKeys()` function from your client application with the appropriate backup method #### **Step 1: Prepare the Eject Operation** Make a custodian-authenticated `POST` request using your Custodian API Key to authorize the ejection: ```swift theme={null} guard let prepareEjectUrl = URL(string: "https://api.portalhq.io/api/v3/custodians/me/clients/\(clientId)/prepare-eject") else { throw URLError(.badURL) } // Prepare ETH wallet let prepareEjectData = try await requests.post( prepareEjectUrl, withBearerToken: nil, andPayload: ["walletId": EthWalletId] ) guard let prepareEthEjectResponse = String(data: prepareEjectData, encoding: .utf8) else { throw PortalExampleAppError.couldNotParseCustodianResponse("Unable to read prepare eject response.") } print("ETH Wallet ejectable until \(prepareEthEjectResponse)") // Prepare Solana wallet let prepareEjectData = try await requests.post( prepareEjectUrl, withBearerToken: nil, andPayload: ["walletId": SolanaWalletId] ) guard let prepareEjectResponseEd25519 = String(data: prepareEjectData, encoding: .utf8) else { throw PortalExampleAppError.couldNotParseCustodianResponse("Unable to read prepare eject response.") } print("Solana Wallet ejectable until \(prepareEjectResponseEd25519)") ``` ```swift theme={null} guard let prepareEjectUrl = URL(string: "https://api.portalhq.io/api/v3/custodians/me/clients/\(clientId)/prepare-eject") else { throw URLError(.badURL) } let prepareEjectData = try await requests.post( prepareEjectUrl, withBearerToken: nil, andPayload: ["walletId": walletId] ) guard let prepareEjectResponse = String(data: prepareEjectData, encoding: .utf8) else { throw PortalExampleAppError.couldNotParseCustodianResponse("Unable to read prepare eject response.") } print("ETH Wallet ejectable until \(prepareEjectResponse)") ``` The `walletId` can be found in the `wallets` property of the `/api/custodians/me/clients/\(clientId)` `GET` request. If you are planning on ejecting multiple wallets for a user, you'll need to make multiple separate requests to "unlock" all requires wallets. #### Step 2: Eject the Wallet After preparation, call the `eject()` or `ejectPrivateKeys()` method with the appropriate backup method that was originally used to secure the wallet: ```swift theme={null} // For Password backup method let privateKey = try await portal.ejectPrivateKeys(.Password) // For Passkey backup method let privateKey = try await portal.ejectPrivateKeys(.Passkey) // For iCloud backup method let privateKey = try await portal.ejectPrivateKeys(.iCloud) // For Google Drive backup method let privateKey = try await portal.ejectPrivateKeys(.GoogleDrive) ``` ```swift theme={null} // For Password backup method let privateKey = try await portal.eject(.Password) // For Passkey backup method let privateKey = try await portal.eject(.Passkey) // For iCloud backup method let privateKey = try await portal.eject(.iCloud) // For Google Drive backup method let privateKey = try await portal.eject(.GoogleDrive) ``` Those functions will: * Retrieve and combine shares from Portal's server * Perform the cryptographic operations to construct the private key/s * Return the private key/s as a string that can be imported into other wallet managers ### Self-Managed Backup (from your server) When using Self-Managed Backups, you store the backup shares in your own infrastructure. To eject a wallet, you'll need to provide both backup shares to the `portal.eject()` or `portal.ejectPrivateKeys()` function. * **User Backup Share** - Encrypted backup share received from `portal.backup()`. * **Custodian Backup Share** - Raw backup share received from the `POST /backup` webhook. **Implementation Requirements** 1. Retrieve the encrypted user backup share from your storage 2. Retrieve the raw custodian backup share/s from your storage 3. Call the eject function with both shares Example Implementation: ```swift theme={null} // Fetch the encrypted user backup share from your API. let encryptedUserBackupShare = try await yourAPI.fetchEncryptedUserBackupShare( userId, backupMethod: .Password ) // Fetch the user's custodian backup share for Ethereum (SECP256k1) let custodianBackupShare = try await yourAPI.fetchCustodianBackupShare( userId, backupMethod: .Password ) // Fetch the user's custodian backup share for Solana (ED25519) let custodianSolanaBackupShare = try await yourAPI.fetchCustodianSolanaBackupShare( userId, backupMethod: .Password ) let privateKeys = try await portal.ejectPrivateKeys( .Password, withCipherText: cipherText, andOrganizationBackupShare: custodianBackupShare, andOrganizationSolanaBackupShare: custodianSolanaBackupShare ) // ✅ ETH & Solana private keys are now available for export print("ETH & Solana private keys: \(privateKeys)") ``` ```swift theme={null} // Fetch the encrypted user backup share from your API. let encryptedUserBackupShare = try await yourAPI.fetchEncryptedUserBackupShare( userId, backupMethod: .Password ) // Fetch the user's custodian backup share from your API. let custodianBackupShare = try await yourAPI.fetchCustodianBackupShare( userId, backupMethod: .Password ) let privateKey = try await portal.eject( .Password, withCipherText: cipherText, andOrganizationBackupShare: custodianBackupShare ) // ✅ The ETH private key is now available for export print("ETH private key: \(privateKey)") ``` ### Cryptographic Curves Portal supports multiple blockchain networks that use different cryptographic curves: * **SECP256K1**: Used by Ethereum and most EVM-compatible blockchains * **ED25519**: Used by Solana and several other blockchains You can learn more about signing algorithms and curves [here](http://ethanfast.com/top-crypto.html). When ejecting wallets, make sure to provide the appropriate backup shares for each curve your application supports. ### Best Practices * Implement clear user confirmations before initiating the eject process * Securely transmit and display private keys to users * Educate users about the security implications of moving from MPC to single key wallets * Consider implementing a cooling-off period before allowing wallet ejection And that's it! Now your users can obtain a private key to import their wallet into another wallet manager. **Related Documentation** * [Backup options](../../../resources/backup-options/gdrive) * [ejectPrivateKeys function reference](../reference/ejectprivatekeys) # Evaluate a transaction Source: https://docs.portalhq.io/sdks/ios/guide/evaluate-a-transaction Before committing to a transaction, it's often useful to simulate its outcome to understand the potential changes and detect any errors preemptively. Portal provides the function **`portal.evaluateTransaction`**, which gives you insights into what will happen upon executing the transaction. This function can perform transaction simulation, security validation, or both. Here's how it works: ```swift theme={null} // Basic transaction evaluation do { let transaction = EvaluateTransactionParam( to: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", value: "1000000000000000000", // 1 ETH data: nil, maxFeePerGas: "30000000000", maxPriorityFeePerGas: "1500000000", gas: "21000", gasPrice: nil ) let evaluation = try await portal.evaluateTransaction( chainId: "eip155:1", transaction: transaction, operationType: .all ) // Check validation results if let validation = evaluation.validation { print("Security Status: \(validation.status)") if validation.status != "VALIDATED" { print("Warning: \(validation.description ?? "Unknown issue")") // Print detected security features validation.features.forEach { feature in print("Security Feature: \(feature.type)") print("Description: \(feature.description)") } } } // Check simulation results if let simulation = evaluation.simulation { print("Simulation Status: \(simulation.status)") // Print asset changes simulation.assetsDiffs.forEach { (token, diffs) in print("\nToken: \(token)") diffs.forEach { diff in print("Incoming transfers:") diff.in.forEach { transfer in print(transfer) } print("Outgoing transfers:") diff.out.forEach { transfer in print(transfer) } } } } } catch { print("Evaluation failed: \(error)") } // Contract interaction evaluation do { let transaction = EvaluateTransactionParam( to: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", value: "0", data: "0xa9059cbb000000000000000000000000742d35cc6634c0532925a3b844bc454e4438f44e0000000000000000000000000000000000000000000000000de0b6b3a7640000", // ERC20 transfer maxFeePerGas: nil, maxPriorityFeePerGas: nil, gas: "65000", gasPrice: "20000000000" ) // Validate only let validation = try await portal.evaluateTransaction( chainId: "eip155:1", transaction: transaction, operationType: .validation ) if let validationResult = validation.validation { // Handle potential security issues switch validationResult.status { case "VALIDATED": print("Transaction is safe") case "WARNING": print("Warning: \(validationResult.description ?? "Unknown warning")") case "BLOCKED": print("Blocked: \(validationResult.reason ?? "Unknown reason")") default: print("Unknown status: \(validationResult.status)") } } } catch { print("Validation failed: \(error)") } ``` The `operationType` parameter determines what type of evaluation is performed. It can be one of the following values: ```swift theme={null} public enum EvaluateTransactionOperationType: String, CaseIterable { case validation // Perform security validation only case simulation // Perform transaction simulation only case all // Perform both validation and simulation } ``` This function returns a `BlockaidValidateTrxRes` containing: * **simulation**: Transaction simulation results including: * **accountAddress**: Address being analyzed * **accountSummary**: Summary of account state changes * **addressDetails**: Detailed information about involved addresses * **assetsDiffs**: Asset balance changes, containing: * **asset**: Asset information * **in**: Incoming transfers * **out**: Outgoing transfers * **exposures**: Risk exposure analysis * **status**: Simulation status * **totalUsdDiff**: Total USD value change * **totalUsdExposure**: Total USD value at risk * **validation**: Security validation results (when `operationType` is `.validation` or `.all`) including: * **classification**: Type of potential security issue * **description**: Detailed description of the validation results * **features**: Array of detected security features * **status**: Validation status * **block**: Block number used for evaluation * **chain**: Chain identifier By incorporating transaction simulations, you can provide your users with a preview of the transaction outcomes and preemptively detect and handle errors, ensuring a smoother user experience. *** And now you are simulating transactions with Portal! 🙌 🚀 Next, we'll explore how to back up the wallet for recovery if the user loses device access. **Related Documentation** * [evaluateTransaction function reference](../reference/evaluatetransaction) # Upgrading to EIP-7702 Source: https://docs.portalhq.io/sdks/ios/guide/evm-account-type Learn how to check wallet account type and upgrade EOA wallets to EIP-7702 using Portal's iOS SDK. Portal's iOS SDK provides EVM Account Type management through the `portal.evmAccountType` API. This enables checking wallet account types and upgrading EOA wallets to EIP-7702 on supported EVM chains. ## Overview The EVM Account Type functionality allows you to: * **Check wallet status** to determine the current account type * **Get wallet addresses** to retrieve the EOA address and (if applicable) the smart contract address * **Upgrade to EIP-7702** to enable ejecting the Portal wallet's EOA private key ## Prerequisites Before using EVM Account Type operations, ensure you have: * A properly initialized Portal client * An active wallet on a supported network (see [Create a wallet](./create-a-wallet)) * Understanding of [EVM Account Type concepts](/resources/evmAccountType) * You fund the [Upgrade Sponsor address with native tokens for the target chain in the Portal Dashboard](https://app.portalhq.io/upgrade-sponsorship) ## Checking Wallet Status Use `getStatus` to check the current account type of your wallet. The response includes both the account status and metadata about the wallet addresses. ```swift theme={null} Task { do { let chainId = "eip155:10143" // Monad Testnet let response = try await portal.evmAccountType.getStatus(chainId: chainId) print("Status: \(response.data.status)") print("EOA Address: \(response.metadata.eoaAddress)") if let smartContractAddress = response.metadata.smartContractAddress { print("Smart Contract Address: \(smartContractAddress)") } if response.data.status == "EIP_155_EOA" { print("Can upgrade to EIP-7702") } else if response.data.status == "EIP_7702_EOA" { print("Already upgraded") } else { print("Cannot upgrade") } } catch { print("Failed to get account status: \(error.localizedDescription)") } } ``` **Response (`EvmAccountTypeResponse`):** * `data.status`: One of `EIP_155_EOA`, `SMART_CONTRACT`, or `EIP_7702_EOA` * `metadata.eoaAddress`: The EOA address associated with the wallet * `metadata.smartContractAddress`: Your smart contract wallet address (optional, may be `nil` for EOA-only accounts) * `metadata.chainId`: The chain identifier *** ## Getting Wallet Addresses Use `getAddresses` to retrieve the EOA and smart contract addresses for your wallet on a given chain. This is a convenience method that returns the EOA and optional smart contract address, equivalent to the address metadata returned by `getStatus`. ```swift theme={null} Task { do { let chainId = "eip155:10143" // Monad Testnet let addresses = try await portal.evmAccountType.getAddresses(chainId: chainId) print("EOA Address: \(addresses.eoaAddress)") if let smartContractAddress = addresses.smartContractAddress { print("Smart Contract Address: \(smartContractAddress)") } } catch { print("Failed to get addresses: \(error.localizedDescription)") } } ``` **Parameters:** * `chainId` (`String`): A CAIP-2 chain identifier (e.g., `"eip155:10143"`) **Response (`EvmAccountTypeAddresses`):** * `eoaAddress` (`String`): The EOA address associated with the wallet * `smartContractAddress` (`String?`): The smart contract wallet address (optional, `nil` for EOA-only accounts) *** ## Upgrading to EIP-7702 Use `upgradeTo7702` to upgrade an EOA wallet to EIP-7702. This method automatically handles the entire upgrade process, including gas subsidization, and returns the transaction hash directly. ```swift theme={null} Task { do { let chainId = "eip155:10143" // Monad Testnet // Upgrade to EIP-7702 let txHash = try await portal.evmAccountType.upgradeTo7702(chainId: chainId) print("Upgrade done! TX: \(txHash)") // Verify final status let status = try await portal.evmAccountType.getStatus(chainId: chainId) print("Final Status: \(status.data.status)") if status.data.status == "EIP_7702_EOA" { print("Successfully upgraded to EIP-7702!") } } catch let error as EvmAccountTypeError { switch error { case .invalidAccountType(let status): print("Cannot upgrade - current status: \(status)") case .unsupportedChainNamespace(let chainId): print("Unsupported chain ID: \(chainId)") default: print("Upgrade error: \(error.localizedDescription)") } } catch { print("Upgrade failed: \(error.localizedDescription)") } } ``` **What happens during the upgrade:** 1. **Chain validation**: Verifies the chain uses the `eip155` namespace 2. **Status check**: Confirms the wallet is currently an `EIP_155_EOA` 3. **Authorization building**: Creates the EIP-7702 authorization list 4. **Signature**: Signs the authorization hash with your wallet 5. **Transaction submission**: Submits the subsidized upgrade transaction (gas is paid by Portal) 6. **Transaction hash**: Returns the on-chain transaction hash **Response:** The method returns a `String` containing the transaction hash of the submitted upgrade transaction. **Error handling:** The method throws typed `EvmAccountTypeError` errors: * `invalidAccountType(status)`: The wallet is not an `EIP_155_EOA` (e.g., already upgraded to `EIP_7702_EOA` or is a `SMART_CONTRACT`) * `unsupportedChainNamespace(chainId)`: The chain does not use the `eip155` namespace * `portalNotInitialized`: The Portal instance is not available * `invalidSignatureResponse`: The signing operation returned an invalid result * `invalidTransactionResponse`: The transaction hash was not returned from the API *** The upgrade process is irreversible per wallet. Once upgraded to EIP-7702, the wallet cannot be reverted to a standard EOA on that chain. *** ## Supported Networks EVM Account Type upgrades are currently available on: * **Monad Testnet** (`eip155:10143`) For a complete list of supported chains, reach out to the Portal team. *** ## Next Steps * Learn about [signing transactions](./sign-a-transaction) * Explore [Portal API methods](./portal-api-methods) * Review [EVM Account Type concepts](/resources/evmAccountType) * Check out [wallet lifecycle management](./manage-wallet-lifecycle-states) # Feature Flags Source: https://docs.portalhq.io/sdks/ios/guide/feature-flags Feature flags allow you to enable or disable specific features in the Portal SDK. This provides flexibility in customizing the behavior of the SDK for your application. ### Overview The `FeatureFlags` struct is used to configure feature flags when initializing the Portal SDK. Each flag corresponds to a specific feature or behavior that can be toggled on or off. #### Example Usage: ```swift theme={null} import PortalSwift // Initialize Portal with custom feature flags let portal = try Portal( "CLIENT_API_KEY_OR_CLIENT_SESSION_TOKEN", featureFlags: FeatureFlags( useEnclaveMPCApi: true ) ) ``` *** ### Available Feature Flags Below is a list of available feature flags and their functionality. #### 1. `useEnclaveMPCApi` * **Type**: `Bool?` * **Default**: `nil` (disabled) * **Description**: Enables the use of the **Enclave MPC API** for signing transactions. When enabled, MPC operations are executed server-side in a secure AWS Nitro Enclave, ensuring consistent and faster signing times. **How It Works** Executing MPC operations on client devices can lead to inconsistent signing times due to variations in device CPU performance. By enabling the `useEnclaveMPCApi` flag, the client key share is transmitted to a **Trusted Execution Environment (TEE)** hosted in an AWS Nitro Enclave. This ensures: 1. **Encrypted Memory**: All data processed in the enclave is encrypted and inaccessible to anyone, including Portal employees. 2. **Verified Execution**: Users can cryptographically verify that their request was handled in a secure enclave using signed measurements. **Example:** ```swift theme={null} import PortalSwift // Initialize Portal with the Enclave MPC API enabled let portal = try Portal( "CLIENT_API_KEY_OR_CLIENT_SESSION_TOKEN", featureFlags: FeatureFlags( useEnclaveMPCApi: true ) ) ``` By setting `useEnclaveMPCApi` to `true`, the Portal instance will use the Enclave MPC API for signing transactions, ensuring faster computation and consistent performance across client devices. *** #### 2. `usePreGeneratedWallet` * **Type**: `Bool?` * **Default**: `nil` (disabled) * **Description**: When enabled, `portal.createWallet()` attempts to claim a pre-generated wallet share instead of running the standard interactive MPC generation. This can make wallet creation faster. You do not need to change how you call `createWallet`. **How It Works** Normally, `createWallet` runs the MPC key generation (DKG) protocol on the device, interactively with the MPC servers, at the moment it is called. With this flag enabled, the SDK asks the Enclave MPC API for the shares instead, and the enclave serves them from a pool it generated ahead of time. There are two independent fallbacks, and they happen at different layers: * **Enclave-side fallback—invisible to the SDK.** If the pool is empty or a share can't be claimed, the enclave generates the shares on demand and returns them in a normal `200 OK` response. The SDK can't tell the two responses apart, and it doesn't need to: the resulting wallet is the same either way, and generation still happens server-side rather than on the device. * **SDK-side fallback—only on an HTTP 5xx.** If the enclave responds with a 5xx, the SDK falls back to running the standard on-device DKG protocol. The fallback is transparent, and `createWallet` returns or throws exactly as it would through the standard flow. Every other failure is thrown as-is, since retrying on the device wouldn't resolve it. | Case | Behavior | | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Flag disabled (default)** | `createWallet` runs the standard on-device DKG protocol, exactly as it does today. | | **Flag enabled, share claimed** | The enclave returns a pre-generated share and `createWallet` completes faster than the standard flow. | | **Pool temporarily unavailable** | The enclave generates the shares on demand and returns a successful response. The SDK does *not* fall back to the on-device flow, and your app sees a normal success. | | **Enclave returns an HTTP 5xx** | The SDK falls back to the standard on-device DKG protocol and completes wallet creation that way. | | **Client already has a wallet** | The enclave responds `400`—`Not eligible to claim a pre-generated wallet`, or `Wallet already exists` when it's caught while finalizing the claim—and `createWallet` throws. This is the most common failure to expect with this flag enabled. | | **Any other failure** | Other 4xx client errors, an invalid API key, a network failure, or a share that fails to decode are thrown as-is. | Claiming a share does not change the security model: shares are still split between the user's device and Portal, and the pre-generated shares are produced in the same Trusted Execution Environment described in the `useEnclaveMPCApi` section above. **When to enable** Enable `usePreGeneratedWallet` when you want faster wallet creation without changing your integration. It helps most on older devices, where running the DKG protocol on the handset is slowest and least consistent. The resulting wallet is identical to one created through the standard flow. **Limitations** * This is a performance optimization only; it doesn't change the API surface, the resulting wallet, or how backup and recovery work. * The SDK only retries on the device when the enclave returns an HTTP 5xx. Other errors, including network failures, propagate normally—so a misconfigured API key, or calling `createWallet` for a client that already has a wallet, produces a failed wallet creation rather than a slow one. Keep your existing error handling around `createWallet`. * Because the enclave generates on demand when the pool is empty, enabling this flag doesn't guarantee the share came from the pool—only that wallet creation didn't run on the device. **Example:** ```swift theme={null} import PortalSwift // Initialize Portal with pre-generated wallets enabled let portal = try Portal( "CLIENT_API_KEY_OR_CLIENT_SESSION_TOKEN", featureFlags: FeatureFlags( usePreGeneratedWallet: true ) ) do { let addresses = try await portal.createWallet() print("My Portal EVM address: \(addresses.ethereum)") print("My Portal Solana address: \(addresses.solana)") } catch { // The SDK already retried on the device if the enclave returned a 5xx, so // reaching this point means the failure was not retryable—most commonly // because this client already has a wallet. print("Failed to create wallet: \(error)") } ``` Every parameter on `FeatureFlags` is optional and defaulted, so you can enable `usePreGeneratedWallet` on its own as shown above, or alongside any other flag. **Types** Claiming a share calls the Enclave MPC API at `POST https://{enclaveMPCHost}/v1/generate`, where `enclaveMPCHost` is configurable and defaults to `mpc-client.portalhq.io`. The response type is public so you can build mocks and tests against it—most integrations never reference it directly, since `createWallet` handles the response for you. ```swift theme={null} // A single curve's share. `share` is the base64-encoded serialized MPC share. public struct GenerateApiCurveShare: Decodable { public let share: String public let id: String } public struct GenerateApiResponse: Decodable { public let secp256k1: GenerateApiCurveShare public let ed25519: GenerateApiCurveShare // The enclave returns uppercase curve names on the wire. enum CodingKeys: String, CodingKey { case secp256k1 = "SECP256K1" case ed25519 = "ED25519" } } ``` # Getting Started Source: https://docs.portalhq.io/sdks/ios/guide/getting-started Follow this guide to integrate Portal in your iOS app. Portal provides MPC **wallets** and dApp **connections** for organizations and their users.\ \ To integrate Portal, an organization adds a **client library** to their mobile app and a few **server API endpoints**. ## Installation We support Cocoa Pods and Swift Package Manager as distribution methods for our swift package. ### Setup Add the `PortalSwift` package through Xcode: 1. In Xcode, right-click on your project in the Project Navigator 2. Select "Add Packages..." 3. In the search field, enter either: * Package name: `PortalSwift` * Repository URL: `https://github.com/portal-hq/PortalSwift` 4. Select your desired version 5. Click "Add Package" You can read more on adding SPM packages [here](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app). 1. Add the following line to your `Podfile`: ```ruby theme={null} pod 'PortalSwift', :git => 'https://github.com/portal-hq/PortalSwift.git' ``` 1. In Terminal, navigate to your project directory and run: ```bash theme={null} pod install ``` *If you are running an xcode project and need to run `pod init` and run into ruby or xcode errors, ensure your xcode project is compatible with an xcode version lower than 14.* 1. After installation: * Close your `.xcodeproj` file * Open the newly generated `.xcworkspace` file * ⚠️ Important: Always use the `.xcworkspace` file for development when using CocoaPods ## Initializing Portal To initialize `Portal` in your application, create a new instance of the `Portal` class included in the package dependency you added. You can provide your Client API Key as the `apiKey` argument during initialization. `rpcConfig` is a map of [CAIP-2 Chain IDs](../../../resources/chain-id-formatting#caip-2-chain-id-format) to their respective RPC URLs. We can now create an instance of the `Portal` class. Below is an example of how you can do this: ```swift theme={null} import PortalSwift let portal = try Portal( "CLIENT_API_KEY_OR_CLIENT_SESSION_TOKEN", withRpcConfig: [ "eip155:1": "https://api.portalhq.io/rpc/v1/eip155/1", "eip155:10143": "https://api.portalhq.io/rpc/v1/eip155/10143", "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": "https://api.mainnet-beta.solana.com", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": "https://api.devnet.solana.com", ], // Optional: Enable feature flags, such as faster wallet creation featureFlags: FeatureFlags(usePreGeneratedWallet: true) ) ``` For faster wallet generation, enable the [`usePreGeneratedWallet`](./feature-flags#2-usepregeneratedwallet) feature flag here—no other code changes are needed. Now that we have our Portal instance, the next step is to generate wallets for your user. Let's create them! If you are using [Client Session Tokens (CSTs)](../../../resources/authentication-and-api-keys), this hint is for you. When your user's CST expires, all Portal SDKs will throw an error on the next MPC Operation the user makes (e.g. creating a wallet, backing up a wallet, recovering a wallet, or signing). That error will include a code **`SESSION_EXPIRED`** in the SDK methods, which you can use as an indicator to refresh your CST. # Enhance your wallets' security with Hypernative Source: https://docs.portalhq.io/sdks/ios/guide/hypernative Learn how to integrate real-time security scanning for transactions, addresses, tokens, NFTs, and URLs using Portal's iOS SDK with Hypernative. Portal's iOS SDK provides comprehensive security scanning capabilities through the `portal.security.hypernative` API. This integration allows you to detect malicious transactions, flagged addresses, compromised tokens, and suspicious URLs before users interact with them. ## Overview The Hypernative integration enables you to: * **Scan transactions** before signing or submission (EVM, EIP-712, Solana) * **Scan addresses** for known malicious actors or compromised contracts * **Validate tokens** to detect scams, honeypots, or security risks * **Check NFTs** for fraudulent collections or suspicious activity * **Verify URLs** to prevent phishing attacks ## Prerequisites Before using Hypernative security scanning, ensure you have: * A properly initialized Portal client * Hypernative integration enabled in your Portal Dashboard (see [Hypernative Integration](../../../integrations/Security/hypernative)) ## Scanning EVM Transactions Use `scanEVMTx` to analyze standard Ethereum transactions before signing or sending. This method scans EVM transactions for malicious contract interactions, suspicious token approvals, and other security risks. ```swift theme={null} Task { do { let transaction = ScanEVMTransaction( chain: "eip155:1", fromAddress: "0x7C01728004d3F2370C1BBC36a4Ad680fE6FE8729", toAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", input: "0x095ea7b300000000000000000000000066ba61be3bab35c0c00038f335850a390b086fe300000000000000000000000000000000000000000fffffffffffffffffffffff", value: 0, nonce: 2340, hash: nil, gas: 3000000, gasPrice: 3000000, maxPriorityFeePerGas: nil, maxFeePerGas: nil ) let request = ScanEVMRequest( transaction: transaction, url: nil, blockNumber: nil, validateNonce: nil, showFullFindings: nil, policy: nil ) let response = try await portal.security.hypernative.scanEVMTx(request: request) if let rawResponse = response.data?.rawResponse { print("Success: \(rawResponse.success)") if let data = rawResponse.data { print("Recommendation: \(data.recommendation)") print("Assessment ID: \(data.assessmentId ?? "N/A")") if let findings = data.findings { print("Findings count: \(findings.count)") } } } } catch { print("Error scanning EVM transaction: \(error)") } } ``` *** ## Scanning EIP-712 Typed Messages Use `scanEip712Tx` to analyze typed structured data before signing. This method is critical for detecting malicious permit signatures, phishing attempts, and unauthorized token approvals. EIP-712 defines a standard for hashing and signing typed structured data, commonly used for gasless approvals and off-chain signatures. ```swift theme={null} Task { do { let domain = ScanEip712Domain( name: "MyToken", version: "1", chainId: "eip155:1", verifyingContract: "0xa0b86991c6218b36c1d19d4a2e9Eb0cE3606eB48", salt: nil ) let types: [String: [ScanEip712TypeProperty]] = [ "EIP712Domain": [ ScanEip712TypeProperty(name: "name", type: "string"), ScanEip712TypeProperty(name: "version", type: "string"), ScanEip712TypeProperty(name: "chainId", type: "uint256"), ScanEip712TypeProperty(name: "verifyingContract", type: "address") ], "Permit": [ ScanEip712TypeProperty(name: "owner", type: "address"), ScanEip712TypeProperty(name: "spender", type: "address"), ScanEip712TypeProperty(name: "value", type: "uint256"), ScanEip712TypeProperty(name: "nonce", type: "uint256"), ScanEip712TypeProperty(name: "deadline", type: "uint256") ] ] let message = ScanEip712TypedData( primaryType: "Permit", types: types, domain: domain, message: [ "owner": AnyCodable("0x7b1363f33b86d16ef7c8d03d11f4394a37d95c36"), "spender": AnyCodable("0x67beb4dd770a9c2cbc7133ba428b9eecdcf09186"), "value": AnyCodable(3000), "nonce": AnyCodable(0), "deadline": AnyCodable(50000000000) ] ) let request = ScanEip712Request( walletAddress: "0x7b1363f33b86d16ef7c8d03d11f4394a37d95c36", chainId: "eip155:1", eip712Message: message, showFullFindings: nil, policy: nil ) let response = try await portal.security.hypernative.scanEip712Tx(request: request) if let rawResponse = response.data?.rawResponse { print("Success: \(rawResponse.success)") if let data = rawResponse.data { print("Recommendation: \(data.recommendation)") print("Assessment ID: \(data.assessmentId ?? "N/A")") } } } catch { print("Error scanning EIP-712 transaction: \(error)") } } ``` *** ## Scanning Solana Transactions Use `scanSolanaTx` to analyze Solana transactions before signing. This method detects malicious program invocations, suspicious token transfers, and other Solana-specific security risks. ```swift theme={null} Task { do { let transaction = ScanSolanaTransaction( message: nil, signatures: nil, rawTransaction: "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQADCQkVR3SiiKbW0l4c3NBsEn6+zn1o0YsyypPwN0GUhg4K5HK0Tb5GckDLYW+MsovQASt5EZ3bSH3nluRJAE69H61w0BRUDTrpYQcXosUun6/z2BROkRoH/1bL7KLU9s4lCav6k3ZZgV6qeZFwu4pu89WoIGaqUxG4C93XwVmmDy81v8qBaCSP4/UZfdo3q1bud/W+ixymkH8IMe0laQZYrSx4Uhyxec67hYm1VqLV7JTSSYaC/fm7KvWtZOSRzEFT2gMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAT4tlY/P4mFG1wDJl0ektVggHiZf73lTlHBVJ3fK0nDoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANG5fPtlMEOI/eXV7aPDlpcdLUKm8L3VoW6k/oJlCNLaBQYABQLARQQABgAJAwYAAAAAAAAABzwACQoLCwgyMzQMNQ0ONjcPEDg5EgETFBUWOhEXGBkaGxwdHh8gISI7IyQlJicCKCkqKywtAwQuLzAxPBFVCg8JAQcHBgYBAAAAAwHwCgYBExUbBgICAAAPAwIAAAYBISMoEQQBGQAPAwIAAAYBLjA2DwMCAAAGAgIAAAAIBgYICAADAQkGCQUFBgACBQAEBwEAAAgCAAUMAgAAADwaAAAAAAAABgAFBGDMBQAEPPm21Wu6wrmHu23/ZFNIumpp+ADooZjd4JQgvjnBxkUJAgEDBqWqCgmmCAUIBwu1tp+gcP/+Ri3C1tRXUbPdgqo6rVsj/qnqC959wTdC/mRARysLz9HS09TW19jZ2tsC1QYsNrdxMcm5Nq5FXZrM0IXpEA+ApFa+pz/JvkLz0+2vnwuztLW2t7i5uru8vgAPvBv8VUeRwDy9yD1NHIH5Ji6ZA+zrmpHejKOz4MP8SwrKy8zNzs/S09TVAdY=", version: "0" ) let request = ScanSolanaRequest( transaction: transaction, url: nil, validateRecentBlockHash: nil, showFullFindings: true, policy: nil ) let response = try await portal.security.hypernative.scanSolanaTx(request: request) if let rawResponse = response.data?.rawResponse { print("Success: \(rawResponse.success)") if let data = rawResponse.data { print("Recommendation: \(data.recommendation)") if let findings = data.findings { print("Findings count: \(findings.count)") } } } } catch { print("Error scanning Solana transaction: \(error)") } } ``` *** ## Scanning Addresses Use `scanAddresses` to check multiple addresses for known security risks. This method identifies malicious contracts, compromised wallets, sanctioned addresses, and other flagged entities. ```swift theme={null} Task { do { let request = ScanAddressesRequest( addresses: [ "0x31c05d73f2333b5a176cfdbb7c5ef96ec7bb04ac", "0x2753a0d37a2ad09be3ccc0afcb650bea8ea57a8f" ], screenerPolicyId: nil ) let response = try await portal.security.hypernative.scanAddresses(request: request) if let data = response.data { for addressResult in data.rawResponse { print("Address: \(addressResult.address)") print("Recommendation: \(addressResult.recommendation)") print("Severity: \(addressResult.severity)") print("Flags count: \(addressResult.flags.count)") } } } catch { print("Error scanning addresses: \(error)") } } ``` *** ## Scanning NFTs Use `scanNfts` to validate NFT collections before displaying or allowing interactions. This method detects fraudulent collections, compromised contracts, and suspicious NFT activity. ```swift theme={null} Task { do { let request = ScanNftsRequest( nfts: [ ScanNftsRequestItem( address: "0x5C1B9caA8492585182eD994633e76d744A876548", chain: nil, evmChainId: "eip155:1" ), ScanNftsRequestItem( address: "0xC2e0cA5FE0b9AbE1B86f3cC0b865448908D20A16", chain: nil, evmChainId: "eip155:1" ) ] ) let response = try await portal.security.hypernative.scanNfts(request: request) if let rawResponse = response.data?.rawResponse { print("Success: \(rawResponse.success)") if let data = rawResponse.data { for nft in data.nfts { print("NFT: \(nft.address)") print("Chain: \(nft.chain ?? "N/A")") print("Accept: \(nft.accept)") } } } } catch { print("Error scanning NFTs: \(error)") } } ``` *** ## Scanning Tokens Use `scanTokens` to validate ERC-20 tokens before allowing swaps, transfers, or approvals. This method detects honeypots, scam tokens, and compromised token contracts. ```swift theme={null} Task { do { let request = ScanTokensRequest( tokens: [ ScanTokensRequestItem( address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", chain: nil, evmChainId: "eip155:1" ) ] ) let response = try await portal.security.hypernative.scanTokens(request: request) if let rawResponse = response.data?.rawResponse { print("Success: \(rawResponse.success)") if let data = rawResponse.data { for token in data.tokens { print("Token: \(token.address)") print("Chain: \(token.chain ?? "N/A")") if let reputation = token.reputation { print("Reputation: \(reputation.recommendation)") } } } } } catch { print("Error scanning tokens: \(error)") } } ``` *** ## Scanning URLs Use `scanURL` to detect phishing sites and malicious domains before users navigate to them. This method is critical for protecting users from social engineering attacks. ```swift theme={null} Task { do { let request = ScanUrlRequest(url: "curve.fi") let response = try await portal.security.hypernative.scanURL(request: request) if let rawResponse = response.data?.rawResponse { print("Success: \(rawResponse.success)") if let data = rawResponse.data { let isMalicious = data.isMalicious print("Is Malicious: \(isMalicious)") if let deepScan = data.deepScanTriggered { print("Deep Scan Triggered: \(deepScan)") } } } } catch { print("Error scanning URL: \(error)") } } ``` *** ## Next Steps * Learn about [signing transactions](./sign-a-transaction) * Explore [transaction evaluation](./evaluate-a-transaction) * Review [Portal API methods](./portal-api-methods) * Check out the [Hypernative Integration setup](../../../integrations/Security/hypernative) # Bridge & Swap with Li.Fi Source: https://docs.portalhq.io/sdks/ios/guide/lifi Learn how to bridge and swap tokens across multiple chains using Portal's iOS SDK with Li.Fi integration. Portal's iOS SDK provides comprehensive cross-chain bridging and swapping capabilities through the `portal.trading.lifi` API. This guide covers getting quotes, finding routes, executing swaps and bridges, and tracking transaction status. ## Overview The Li.Fi functionality allows you to: * **Get quotes** for bridging or swapping tokens across chains * **Find routes** to discover the best paths for your cross-chain transfers * **Execute swaps and bridges** by signing and submitting transactions * **Track transaction status** for cross-chain transfers ## Prerequisites Before using Li.Fi operations, ensure you have: * A properly initialized Portal client * An active wallet with the required token(s) on the source network (see [Create a wallet](./create-a-wallet)) * Li.Fi integration enabled in your Portal Dashboard (see [Li.Fi Integration](../../../integrations/Trading/lifi)) ## High-Level Methods `tradeAsset` runs the entire bridge or swap in one call. `pollStatus` exposes the same Li.Fi status poller `tradeAsset` uses internally, for manual flows where you already have a transaction hash. If you only need to move tokens, use `tradeAsset`. Reach for the [low-level methods](#low-level-methods) when you need to inspect routes before committing, run your own signing, or drive a custom UI. ### tradeAsset Runs the end-to-end Li.Fi flow: 1. Discover routes (`getRoutes`) 2. Select a route (`routeIndex`, default `0`) 3. Build each step (`getRouteStep`) 4. Sign and broadcast that step's transaction 5. Wait for on-chain confirmation of that step 6. Poll Li.Fi status until the step reaches a terminal state 7. Continue to the next step Steps execute **sequentially**, never in parallel. Signing and confirmation for each step happen on that step's own chain, which the SDK resolves from the step itself — so a multi-chain route signs on each chain in turn without you managing it. **Confirmation is strict.** Every step must confirm on-chain before the next begins. There is no optimistic fallback: a revert or a confirmation timeout aborts the whole trade and throws. ### Signature ```swift theme={null} func tradeAsset(params: LifiTradeAssetParams) async throws -> LifiTradeAssetResult ``` **Essential parameters** | Parameter | Type | Required | Description | | ------------- | ---------------------- | --------------- | ------------------------------------------------------------------------------------------------- | | `fromChain` | `String` | Yes | Source chain. Use CAIP-2 (`"eip155:8453"`), as everywhere else in this guide. | | `toChain` | `String` | Yes | Destination chain, same format. | | `fromToken` | `String` | Yes | Source token contract address or symbol. | | `toToken` | `String` | Yes | Destination token contract address or symbol. | | `amount` | `String` | Yes | Amount in the token's base units, as an integer string (for example wei for an 18-decimal token). | | `fromAddress` | `String?` | No, but pass it | Sending wallet address. See the note below. | | `toAddress` | `String?` | No | Receiving wallet address. Falls back to `fromAddress` when omitted. | | `routeIndex` | `Int?` | No | Which discovered route to execute. Default `0`. | | `onProgress` | `LifiProgressHandler?` | No | Fired at each stage. See [Progress lifecycle](#progress-lifecycle). | `fromAddress` is optional in the type system but the SDK **does not fill it in for you** — it is forwarded to Li.Fi exactly as given. Omitting it means routes are quoted without a sender, while the transaction is still signed by your Portal wallet, so the quote may not match what actually executes. Pass `try await portal.getAddress("eip155:8453")` explicitly. **Configuring the signer and confirmation** Unlike the React Native and Web SDKs, `tradeAsset` takes **no second options argument**. The signing and confirmation hooks are injected once, when the `Lifi` instance is constructed: ```swift theme={null} public init( api: PortalLifiTradingApiProtocol, signAndSendTransaction: LifiSignAndSendTransaction? = nil, waitForConfirmation: LifiWaitForConfirmation? = nil, stepPollOptions: LifiPollStatusOptions = LifiPollStatusOptions(everyMs: 10_000, initialDelayMs: 0, timeoutMs: 600_000) ) ``` `Portal` wires both automatically, so `portal.trading.lifi.tradeAsset(params:)` works with no setup. The default confirmation poller retries 30 times at 2-second intervals. **Overriding the defaults.** `portal.trading` is built lazily by `Portal` with its own closures already supplied, so the instance at `portal.trading.lifi` cannot be reconfigured after the fact, and there is no per-call override. `Trading` has an internal initializer, so you cannot construct one yourself either — build a `Lifi` directly and call `tradeAsset` on it: ```swift theme={null} let lifi = Lifi( api: portal.api.lifi, signAndSendTransaction: { transaction, chainId in try await mySigner.signAndSend(transaction, chainId: chainId) }, waitForConfirmation: { txHash, chainId -> LifiConfirmationResult in let receipt = try await myConfirmer.waitForReceipt(txHash: txHash, chainId: chainId) return receipt.succeeded ? .confirmed : .reverted }, stepPollOptions: LifiPollStatusOptions(everyMs: 5_000, initialDelayMs: 0, timeoutMs: 300_000) ) let result = try await lifi.tradeAsset(params: params) ``` `stepPollOptions` controls the per-step Li.Fi polling inside `tradeAsset` and is only reachable this way. `waitForConfirmation` returns a three-state enum rather than a boolean, so a revert and a timeout stay distinguishable: | `LifiConfirmationResult` | Meaning | Effect on `tradeAsset` | | ------------------------ | -------------------------------------------------------------------- | --------------------------------------------------------------------- | | `.confirmed` | Mined with a success status. | Proceeds to the next step. | | `.reverted` | Mined but the receipt status was not `0x1`. | Throws `LifiTradeAssetError.transactionConfirmationFailed(txHash)`. | | `.timedOut` | Still pending, or the node was unreachable. Not a definitive revert. | Throws `LifiTradeAssetError.transactionConfirmationTimedOut(txHash)`. | **Return value** | Field | Type | Description | | -------- | ------------ | --------------------------------------------------------------------------- | | `hashes` | `[String]` | One transaction hash per executed step, in execution order. | | `steps` | `[LifiStep]` | The enriched steps that were executed, with `transactionRequest` populated. | | `route` | `LifiRoute` | The route that was selected and executed. | ### Example (progress reporting) ```swift theme={null} import PortalSwift do { let fromAddress = try await portal.getAddress("eip155:8453") let result = try await portal.trading.lifi.tradeAsset( params: LifiTradeAssetParams( fromChain: "eip155:8453", toChain: "eip155:42161", fromToken: "ETH", toToken: "USDC", amount: "1000000000000", fromAddress: fromAddress, onProgress: { status, data in switch status { case .signing: print("Signing step \((data.stepIndex ?? 0) + 1) of \(data.totalSteps ?? 0)") case .submitted: print("Submitted: \(data.txHash ?? "")") case .complete: print("Trade complete") case .failed: print("Failed: \(data.errorMessage ?? "unknown")") default: break } } ) ) print("Hashes: \(result.hashes)") print("Executed steps: \(result.steps.count)") } catch LifiTradeAssetError.noRoutesFound { print("No route available for this pair") } catch let LifiTradeAssetError.transactionConfirmationFailed(txHash) { print("Transaction reverted on-chain: \(txHash)") } catch { print("tradeAsset failed: \(error)") } ``` ### Example (minimal) ```swift theme={null} let fromAddress = try await portal.getAddress("eip155:8453") let result = try await portal.trading.lifi.tradeAsset( params: LifiTradeAssetParams( fromChain: "eip155:8453", toChain: "eip155:42161", fromToken: "ETH", toToken: "USDC", amount: "1000000000000", fromAddress: fromAddress ) ) ``` ### Errors `tradeAsset` and `pollStatus` throw `LifiTradeAssetError`: | Case | When | | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | `missingSigner` | No signing closure on the `Lifi` instance. Cannot happen on `portal.trading.lifi`. | | `missingConfirmation` | No confirmation closure on the `Lifi` instance. Cannot happen on `portal.trading.lifi`. | | `noRoutesFound` | Li.Fi returned no routes for the requested trade. | | `routeIndexOutOfBounds` | `routeIndex` is negative or beyond the number of discovered routes. | | `routeHasNoSteps` | The selected route contains no steps. | | `missingTransactionRequest` | A step came back without a transaction request to sign. | | `invalidTransactionRequest` | A step's transaction request was malformed — missing fields, wrong shape, or an invalid value such as a negative amount. | | `transactionConfirmationFailed(String)` | The step's transaction reverted on-chain. Carries the transaction hash. | | `transactionConfirmationTimedOut(String)` | Confirmation could not be determined before timing out. Carries the transaction hash. The transaction may still be pending. | | `lifiTransferFailed(String)` | Li.Fi reported a `FAILED` terminal state for the transfer. | | `pollTimeout` | Status polling exceeded the configured timeout. | All cases conform to `LocalizedError`, so `error.localizedDescription` gives a readable message. If the surrounding `Task` is cancelled, `tradeAsset` throws `CancellationError` and **does not** emit a `.failed` progress event. A UI that only dismisses its progress state on `.failed` or `.complete` will hang on cancellation — handle `CancellationError` separately. ### Progress lifecycle `onProgress` receives a `LifiTradeAssetProgressStatus` and a `LifiTradeAssetProgressData`. Every field on the data struct is optional; which ones are populated depends on the stage: | Status | Raw value | Populated data | | ----------------- | ----------------- | ------------------------------------------------ | | `.fetchingRoutes` | `fetching_routes` | — | | `.routeSelected` | `route_selected` | `routeIndex`, `route`, `totalSteps` | | `.preparingStep` | `preparing_step` | `routeIndex`, `stepIndex`, `totalSteps`, `route` | | `.signing` | `signing` | above, plus `step` | | `.submitted` | `submitted` | above, plus `txHash` | | `.confirming` | `confirming` | above, plus `txHash` | | `.lifiPending` | `lifi_pending` | above, plus `lifiStatus` on later emissions | | `.stepDone` | `step_done` | above | | `.complete` | `complete` | `route`, `totalSteps` | | `.failed` | `failed` | `errorMessage` | `txHash` is `nil` until `.submitted`. `errorMessage` is only ever set on `.failed`. ### pollStatus Polls Li.Fi for the status of a transfer until it reaches a terminal state. Use it when you have submitted a transaction yourself and want the same polling behavior `tradeAsset` uses internally. The protocol requirement takes three arguments, and three convenience overloads cover the common cases: ```swift theme={null} // Protocol requirement func pollStatus( request: LifiStatusRequest, onUpdate: ((LifiStatusRawResponse) -> Bool)?, options: LifiPollStatusOptions ) async throws -> LifiStatusRawResponse // Convenience overloads func pollStatus(request: LifiStatusRequest) async throws -> LifiStatusRawResponse func pollStatus(request: LifiStatusRequest, options: LifiPollStatusOptions) async throws -> LifiStatusRawResponse func pollStatus(request: LifiStatusRequest, onUpdate: @escaping (LifiStatusRawResponse) -> Bool) async throws -> LifiStatusRawResponse ``` Returning `false` from `onUpdate` stops polling early and returns the last status received — it is not an error. Returning `true` continues. ```swift theme={null} let txHash = "0xYOUR_TRANSACTION_HASH" let final = try await portal.trading.lifi.pollStatus( request: LifiStatusRequest( txHash: txHash, fromChain: "eip155:8453", toChain: "eip155:42161" ), onUpdate: { update in print("Status: \(update.status.rawValue)") return true // return false to stop polling early } ) print("Final status: \(final.status.rawValue)") ``` ### pollStatus options | Option | Type | Default | Description | | ---------------- | ----- | -------- | -------------------------------------------------------------------------------------------------------- | | `everyMs` | `Int` | `10000` | Interval between polls, in milliseconds. Values below `100` are raised to `100` to prevent busy-waiting. | | `initialDelayMs` | `Int` | `0` | Delay before the first poll, in milliseconds. | | `timeoutMs` | `Int` | `600000` | Overall polling timeout, in milliseconds. Exceeding it throws `LifiTradeAssetError.pollTimeout`. | ```swift theme={null} let txHash = "0xYOUR_TRANSACTION_HASH" let final = try await portal.trading.lifi.pollStatus( request: LifiStatusRequest(txHash: txHash, fromChain: "eip155:8453", toChain: "eip155:42161"), options: LifiPollStatusOptions(everyMs: 5_000, initialDelayMs: 10_000, timeoutMs: 300_000) ) ``` *** ## Low-level methods The rest of this guide covers the individual Li.Fi methods. Use them when you need control over route selection, signing, or status tracking that `tradeAsset` does not expose. ## Getting a Quote Use the `getQuote` method to get a quote for bridging or swapping tokens across chains. ```swift theme={null} do { let userAddress = try await portal.getAddress("eip155:1") let request = LifiQuoteRequest( fromChain: "eip155:1", toChain: "eip155:137", fromToken: "ETH", toToken: "USDC", fromAddress: userAddress, fromAmount: "1000000000000" // 0.000001 ETH in wei ) let response = try await portal.trading.lifi.getQuote(request: request) if let rawResponse = response.data?.rawResponse { // Process quote response if let estimate = rawResponse.estimate { print("From amount: \(estimate.fromAmount)") print("To amount: \(estimate.toAmount)") print("Execution duration: \(estimate.executionDuration)s") } // Sign and submit the transaction if transactionRequest is available if let transactionRequest = rawResponse.transactionRequest { try await executeTransaction(transactionRequest, chainId: request.fromChain) } } } catch { print("Error getting quote: \(error)") } ``` The response includes a `transactionRequest` object with the transaction details you'll need to sign and submit. ## Finding Routes Use the `getRoutes` method to discover available routes for your cross-chain transfer. ```swift theme={null} do { let userAddress = try await portal.getAddress("eip155:1") let request = LifiRoutesRequest( fromChainId: "eip155:1", fromAmount: "1000000000000", // 0.000001 ETH in wei fromTokenAddress: "ETH", toChainId: "eip155:137", toTokenAddress: "USDC", fromAddress: userAddress ) let response = try await portal.trading.lifi.getRoutes(request: request) if let rawResponse = response.data?.rawResponse { let routes = rawResponse.routes // Find recommended route let recommendedRoute = routes.first { route in route.tags?.contains("RECOMMENDED") ?? false } ?? routes.first if let route = recommendedRoute { print("Selected route: \(route.id)") print("Steps: \(route.steps.count)") print("From: \(route.fromAmountUSD) USD") print("To: \(route.toAmountUSD) USD") // Process route steps try await processRouteSteps(route.steps, fromChainId: request.fromChainId) } } } catch { print("Error getting routes: \(error)") } ``` The response includes an array of routes with estimates, fees, and gas costs. Routes may be tagged as `RECOMMENDED`, `CHEAPEST`, or `FASTEST`. ## Collecting Integrator Fees Pass `integrator` and `fee` to collect a fee on each swap or bridge. `integrator` must be the exact integrator ID that you registered in the [LI.FI Portal](https://portal.li.fi/). `fee` is a decimal, not a percentage, so `0.005` means 0.5%. Valid values are `0 <= fee < 1`. LI.FI adds its own platform fee on top of your `fee`, so the total fee in the quote is higher than the value you pass. On `getQuote`, these are top-level fields: ```swift theme={null} let request = LifiQuoteRequest( fromChain: "eip155:1", toChain: "eip155:137", fromToken: "ETH", toToken: "USDC", fromAddress: userAddress, fromAmount: "1000000000000", integrator: "YOUR_INTEGRATOR_ID", fee: 0.005 // 0.5% ) ``` On `getRoutes`, they live inside `options`: ```swift theme={null} let request = LifiRoutesRequest( fromChainId: "eip155:1", fromAmount: "1000000000000", fromTokenAddress: "ETH", toChainId: "eip155:137", toTokenAddress: "USDC", options: LifiRoutesRequestOptions( integrator: "YOUR_INTEGRATOR_ID", fee: 0.005 // 0.5% ), fromAddress: userAddress ) ``` The quote response shows the fee breakdown in `estimate.feeCosts[]`. Your share appears as `integratorFee` in the `feeSplit` object: ```json theme={null} { "estimate": { "feeCosts": [ { "name": "LIFI Fixed Fee", "amount": "750", "percentage": "0.0075", "feeSplit": { "lifiFee": "250", "integratorFee": "500", "recipients": [ { "name": "lifi", "type": "FIXED", "fee": "250" }, { "name": "YOUR_INTEGRATOR_ID", "type": "FIXED", "fee": "500" } ] } } ] } } ``` Set up your integrator ID and fee receiver wallets in the [LI.FI Portal](https://portal.li.fi/) before you pass `fee`. LI.FI pays collected fees to those wallets, and you withdraw them from the LI.FI Portal. If you omit `integrator`, Portal sends its own default value and your fee configuration does not apply. The `referrer` parameter is an optional tracking string only. It does not control where fees are paid. New fee wallet configuration can take up to roughly 30 minutes to activate. Until then, quotes that pass `fee` fail with LI.FI error 1011 (`not configured for collecting fees on chain`). ## Getting Route Step Details Use the `getRouteStep` method to get detailed transaction information for a specific route step, including an unsigned transaction that you can then sign and submit to an RPC provider (the `transactionRequest` field). ```swift theme={null} func getStepTransactionDetails(step: LifiStep) async throws -> LifiStep? { let stepRequest = step as LifiStepTransactionRequest let response = try await portal.trading.lifi.getRouteStep(request: stepRequest) if let rawResponse = response.data?.rawResponse { return rawResponse } return nil } ``` The response includes a `transactionRequest` object with the unsigned transaction that you can sign and submit. ## Executing Swaps and Bridges After getting a quote or route step details, extract the transaction details from the `transactionRequest` object and sign the transaction. Extract the `from`, `to`, `value`, and `data` fields to sign and submit the transaction. ### Approving ERC-20 Tokens If your `fromToken` is an ERC-20, the Li.Fi router cannot move it on your behalf until you grant an on-chain allowance. Skip this step when the `fromToken` is the chain's native asset (its `address` is `0x0000000000000000000000000000000000000000`). Build the approval transaction with the `portal.delegations.approve(request:)` method, then sign each transaction it returns with the same `eth_sendTransaction` flow used to sign the swap. Call this helper after obtaining a quote and before calling `executeTransaction`: ```swift theme={null} let nativeTokenAddress = "0x0000000000000000000000000000000000000000" func approveErc20IfNeeded( quote: LifiQuoteResponse, fromAmount: String, fromChainId: String ) async throws { guard let action = quote.data?.rawResponse?.action, let estimate = quote.data?.rawResponse?.estimate, let fromToken = action.fromToken, fromToken.address != nativeTokenAddress else { // Native asset — no approval needed. return } // Convert the raw fromAmount into the token's primary denomination // (e.g. raw "10000" with 6 decimals → "0.01"). Use a Decimal-aware // conversion to preserve precision for large values. let amount = formatUnits(fromAmount, decimals: fromToken.decimals) let request = ApproveDelegationRequest( chain: fromChainId, token: fromToken.address, delegateAddress: estimate.approvalAddress, amount: amount ) let response = try await portal.delegations.approve(request: request) guard let transactions = response.transactions else { return } for tx in transactions { var txDict: [String: String] = [ "from": tx.from, "to": tx.to ] if let data = tx.data { txDict["data"] = data } if let value = tx.value { txDict["value"] = value } let txResponse = try await portal.request( chainId: fromChainId, method: .eth_sendTransaction, params: [txDict], options: RequestOptions(signatureApprovalMemo: "Approve token for Li.Fi") ) if let txHash = txResponse.result as? String { _ = await waitForConfirmation(txHash: txHash, chainId: fromChainId) } } } ``` This step only applies when the `fromToken` is an ERC-20. Native-asset swaps (ETH, MATIC, etc.) skip it. For more on the delegations API, see the [Manage Token Delegations](./delegations) guide. ### Signing and Submitting Transactions ```swift theme={null} func executeTransaction(_ transactionRequest: AnyCodable, chainId: String) async throws { // Extract transaction parameters guard let txParams = transactionRequest.value as? [String: Any], let from = txParams["from"] as? String, let to = txParams["to"] as? String else { throw NSError(domain: "LiFi", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid transaction parameters"]) } // Extract value (default to 0x0 if not present) var value = "0x0" if let valueString = txParams["value"] as? String { value = valueString } // Extract data let data = txParams["data"] as? String ?? "0x" // Create transaction let ethTransaction = ETHTransactionParam( from: from, to: to, value: value, data: data ) // Sign and send let sendResponse = try await portal.request( chainId: chainId, method: .eth_sendTransaction, params: [ethTransaction], options: RequestOptions(signatureApprovalMemo: "Bridge & swap via Li.Fi") ) if let txHash = sendResponse.result as? String { print("Transaction submitted: \(txHash)") // Wait for on-chain confirmation let confirmed = await waitForConfirmation(txHash: txHash, chainId: chainId) if confirmed { print("Transaction confirmed") } } } ``` The `transactionRequest` from Li.Fi may include `gasPrice` and `gasLimit` fields. You can remove these if you want Portal to estimate the gas for you, or include them if you want to use Li.Fi's estimates. ### Processing Multiple Route Steps For routes with multiple steps, process them sequentially: ```swift theme={null} func processRouteSteps(_ steps: [LifiStep], fromChainId: String) async throws { for (index, step) in steps.enumerated() { print("Processing step \(index + 1)/\(steps.count): \(step.tool)") // 1. Get transaction details for this step guard let stepWithTx = try await getStepTransactionDetails(step: step), let transactionRequest = stepWithTx.transactionRequest else { throw NSError(domain: "LiFi", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to get transaction details"]) } // 2. Sign and submit the transaction try await executeTransaction(transactionRequest, chainId: fromChainId) print("Step \(index + 1) completed") } } ``` ### Waiting for Transaction Confirmation ```swift theme={null} func waitForConfirmation( txHash: String, chainId: String, maxAttempts: Int = 30, delaySeconds: UInt64 = 2 ) async -> Bool { for _ in 0.. Bool { for attempt in 0.. **Testnets are not supported.** ## Next Steps * Learn about [signing transactions](./sign-a-transaction) * Explore [sending tokens](./send-tokens) * Check out [Portal API methods](./portal-api-methods) # Manage ERC20 tokens Source: https://docs.portalhq.io/sdks/ios/guide/manage-erc20-tokens This guide will walk you through how your Portal Wallets work with ERC20 tokens, how to interface with ERC20 Token Contracts, and how to use the Portal Provider to transfer ERC20 tokens. ## What are ERC20 tokens? [ERC20 is an Ethereum standard](https://ethereum.org/en/developers/docs/standards/tokens/erc-20/) that defines a unified interface for standard token behavior in a smart contract. This allows for custom tokens to be created beyond the native token for that chain (ETH in the case of Ethereum). Examples of ERC20 tokens include stablecoins like [USDT](https://etherscan.io/token/0xdac17f958d2ee523a2206206994597c13d831ec7) & [USDC](https://etherscan.io/token/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48) or Defi protocol tokens like [UNI](https://etherscan.io/token/0x1f9840a85d5af5bf1d1762f925bdaddc4201f984). The ERC20 standard defines just a few Solidity functions that these contracts must implement: ```solidity theme={null} function name() public view returns (string) function symbol() public view returns (string) function decimals() public view returns (uint8) function totalSupply() public view returns (uint256) function balanceOf(address _owner) public view returns (uint256 balance) function transfer(address _to, uint256 _value) public returns (bool success) function transferFrom(address _from, address _to, uint256 _value) public returns (bool success) function approve(address _spender, uint256 _value) public returns (bool success) function allowance(address _owner, address _spender) public view returns (uint256 remaining) ``` In order to send or check the balance of your ERC20 tokens you must interact with the smart contract. Depending on if those functions are **Read-Only** or **State Changing** you interact with these functions in two different ways. #### Read-Only Functions Notice that these functions (shown as their [Solidity](https://ethereum.org/en/developers/docs/smart-contracts/languages/#solidity) function signatures) all contain the `view` keyword. This means that these functions can be invoked using [`eth_call` RPC method](https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_call) and do **not** change the state of the blockchain. This also means that calling these functions does not require gas. ```solidity theme={null} function name() public view returns (string) function symbol() public view returns (string) function decimals() public view returns (uint8) function totalSupply() public view returns (uint256) function balanceOf(address _owner) public view returns (uint256 balance) function allowance(address _owner, address _spender) public view returns (uint256 remaining) ``` #### State Changing Functions These functions do not contain `view` keyword, which means they **do** change the state of the blockchain. This requires sending a signed transaction to the blockchain, along with gas, to update the global state. These functions are invoked using the [`eth_sendTransaction` RPC method.](https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_sendtransaction) ```solidity theme={null} function transfer(address _to, uint256 _value) public returns (bool success) function transferFrom(address _from, address _to, uint256 _value) public returns (bool success) function approve(address _spender, uint256 _value) public returns (bool success) ``` ## Transferring ERC20 with your Portal Wallet The Portal SDK supports the processing of web3 transactions using the Portal Provider. Because of this, working with ERC20 tokens using your Portal Wallet is very similar to working with ETH itself. The main difference is that you'll need to interface with the ERC20 token's contract in order to build the appropriate transaction before signing with the Portal Provider. This can be thought of in three basic steps: 1. Gathering inputs for generating a transaction 2. Using Web3.swift to generate an ERC20 transaction 3. Using Provider to sign and send the transaction ### Gathering inputs for generating a transaction In this example, we'll be looking at transferring ERC20 tokens from your Portal Wallet to another wallet (sending ERC20 funds to another wallet). In order to accomplish this, you'll need your application to manage certain inputs required to generate the transaction: the receiver's address, and the token address for the ERC20 token being sent. \ \ In this case, we'll use Uniswap (UNI) as an example, so we'll be using the `tokenAddress` for UNI ([`0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984`](https://etherscan.io/address/0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984)). ### Using Web3.swift to generate an ERC20 transaction The [`Web3.swift` library](https://github.com/Boilertalk/Web3.swift) allows you to easily interface with token contracts to build the appropriate transaction payload for an ERC20 transaction without needing to manually build `eth_call` requests. We'll be using it for this purpose in this example. The following code demonstrates how to use the `Web3.swift` library to access an instance of the UNI Contract and generating a basic `send` transaction. In order to use Web3.swift in your application, you'll need to add the package as a dependency. More details on adding Web3.swift to your project can be found [here](https://github.com/Boilertalk/Web3.swift#installation). ```swift theme={null} import Web3 import Web3ContractABI // Get the Gateway URL from the Portal Provider guard let gatewayUrl = portal?.getRpcUrl(forChainId: "eip155:11155111") else { // Add application logic to handle this scenario return } // Get the Portal Wallet address guard let address = portal?.address else { // Add application logic to handle this scenario return } // Create an instance of Web3.swift let web3 = Web3(rpcURL: gatewayUrl) // Get an instance of the UNI Contract to generate transactions let contract = web3.eth.Contract( type: GenericERC20Contract.self, address: "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984" // UNI token address ) // Get the correct decimal conversion for the UNI smart contract let conversionFactor: Double = 1e18 let value = BigUInt(amount * conversionFactor) // Generate the ERC20 transaction using the UNI Contract guard let tx = contract .transfer( // eip55 is dependent on the wallet receiving funds to: try EthereumAddress(hex: toAddress, eip55: false) value: value ) .createTransaction( nonce: 0, // Here you'd want to provide the actual nonce for this transaction gasPrice: EthereumQuantity(quantity: 21.gwei), maxFeePerGas: nil, maxPriorityFeePerGas: nil, gasLimit: 100_000, // eip55 will be false for Portal MPC Wallets from: try EthereumAddress(hex: address, eip55: false), value: 0, // This is the value of the native token, not the ERC20 amount accessList: [:], transactionType: .legacy ) else { // Add application logic to handle this scenario return } ``` ### Using Portal to sign and send the transaction Once you've generated a transaction using the token Contract, you can use it to create a request using your Portal instance to sign and send the transaction. ```swift theme={null} Task { do { // Ethereum Sepolia let chainId = "eip155:11155111" // CAIP-2 format. // Obtain the eip155 address of the user's wallet. guard let eip155Address = await portal.getAddress(chainId) else { throw PortalExampleAppError.addressNotFound() } // Create the transaction object. let transaction = [ "from": eip155Address, "to": "0xRecipientAddress", // Sending the tx to the smart contract to execute the smart contract function call in the data field "gasPrice": tx.gasPrice.hex(), "value": "0x0", "data": tx.data.hex(), "nonce": tx.nonce.hex() ] // Make the eth_sendTransaction request. let requestResponse = try await portal.request( chainId: chainId, method: .eth_sendTransaction, params: [transaction], options: RequestOptions(signatureApprovalMemo: "Send ERC20 tokens") // Optional signature approval memo to use for the request ) } } ``` ## The sendERC20Token() function Now that we've covered the steps required to sign and send a transaction using ERC20 tokens, let's look at an example of a full function for sending ERC20 tokens to another wallet. ```swift theme={null} func sendERC20Token(tokenAddress: String, toAddress: String, amount: Int64) throws { // Get the Gateway URL from the Portal Provider guard let gatewayUrl = portal?.getRpcUrl(forChainId: "eip155:11155111") else { return } // Ensure the Portal Address is set guard let address = portal?.address else { // Probably throw an error here return } // Initialize Web3 let web3 = Web3(rpcURL: gatewayUrl) do { // Transform the receiver address let receiverAddress = try EthereumAddress(hex: toAddress, eip55: false) // eip55 is dependent on the wallet receiving funds // Transfor the sender address let senderAddress = try EthereumAddress(hex: address, eip55: false) // eip55 will be false for Portal MPC Wallets // Transform the token address let tokenContractAddress = try EthereumAddress(hex: tokenAddress, eip55: true) // eip55 will be true for Token Contracts // Initialize the token contract let contract = web3.eth.Contract( type: GenericERC20Contract.self, address: tokenContractAddress ) // Get the correct decimal conversion for the UNI smart contract let conversionFactor: Double = 1e18 // In the case of UNI it is 18. let value = BigUInt(amount * conversionFactor) // Generate the transaction for Portal to execute guard let tx = contract .transfer(to: receiverAddress, value: value) .createTransaction( nonce: 0, // Here you'd want to provide the actual nonce for this transaction gasPrice: EthereumQuantity(quantity: 21.gwei), maxFeePerGas: nil, maxPriorityFeePerGas: nil, gasLimit: 100_000, from: senderAddress, value: 0, accessList: [:], transactionType: .legacy ) else { return } Task { do { // Ethereum Sepolia let chainId = "eip155:11155111" // CAIP-2 format. // Obtain the eip155 address of the user's wallet. guard let eip155Address = await portal.getAddress(chainId) else { throw PortalExampleAppError.addressNotFound() } // Create the transaction object. let transaction = [ "from": eip155Address, "to": tokenAddress, "gasPrice": tx.gasPrice.hex(), "value": "0x0", "data": tx.data.hex(), "nonce": tx.nonce.hex() ] // Make the eth_sendTransaction request. let requestResponse = try await portal.request( chainId: chainId, method: .eth_sendTransaction, params: [transaction], options: RequestOptions(signatureApprovalMemo: "Send ERC20 tokens") // Optional signature approval memo to use for the request ) } } } catch { // Handle errors } } ``` # Manage wallet lifecycle states Source: https://docs.portalhq.io/sdks/ios/guide/manage-wallet-lifecycle-states Users can have multiple states in their wallet lifecycle: having a wallet, having wallet backups, having certain recovery methods available, and more. After initializing Portal for your user, it's crucial to determine their wallet's lifecycle state to ensure a healthy wallet state. We provide several helper methods to easily derive the state of your users' wallets: ```swift theme={null} // Checks if the user has created a wallet on any device. await portal.doesWalletExist() // Checks if the user's wallet share is on their current device. await portal.isWalletOnDevice() // Checks if the user has backed up their wallet. await portal.isWalletBackedUp() // Checks if the user can recover their wallet. await portal.isWalletRecoverable() // Returns a list of available recovery methods based on the user's backups. await portal.availableRecoveryMethods() ``` These methods can be used together to understand the user's wallet state and determine the next steps. For example: ```swift theme={null} func manageWalletState(portal: Portal) async { do { // Determine the wallet's state. let walletExists = try await portal.doesWalletExist() let walletExistsOnDevice = try await portal.isWalletOnDevice() let walletIsRecoverable = try await portal.isWalletRecoverable() if !walletExists { // Create and back up the wallet using portal.createWallet() and portal.backup(). } else if !walletExistsOnDevice { if walletIsRecoverable { // Recover the wallet using portal.recover(). } else { // Inform the user to back up the wallet on the original device. } } else { // The wallet is ready to use. } } catch { // Handle any errors that occur. } } ``` And that's it! You're now helping your users maintain a safe and secure experience with their wallets while using your apps. **Related Documentation** * [doesWalletExist function reference](../reference/doeswalletexist) * [isWalletOnDevice function reference](../reference/iswalletondevice) * [isWalletBackedUp function reference](../reference/iswalletbackedup) * [isWalletRecoverable function reference](../reference/iswalletrecoverable) * [availableRecoveryMethods function reference](../reference/availablerecoverymethods) # MPC progress callbacks Source: https://docs.portalhq.io/sdks/ios/guide/mpc-progress-callbacks This section describes how to check in on the status of each MPC operation ## MPC Statuses These are all the possible statuses that will be returned through the callbacks on the MPC operations. ```swift theme={null} case generatingShare = "Generating share" case parsingShare = "Parsing share" case storingShare = "Storing share" case encryptingShare = "Encrypting share" case decryptingShare = "Decrypting share" case readingShare = "Reading share" case recoveringBackupShare = "Recovering backup share" case recoveringSigningShare = "Recovering signing share" case done = "Done" ``` ### Generate Status Flow ```swift theme={null} case generatingShare = "Generating share" case parsingShare = "Parsing share" case storingShare = "Storing share" case done = "Done" ``` ### Backup Status Flow ```swift theme={null} case readingShare = "Reading share" case generatingShare = "Generating share" case parsingShare = "Parsing share" case encryptingShare = "Encrypting share" case storingShare = "Storing share" case done = "Done" ``` ### Recover Status Flow ```swift theme={null} case readingShare = "Reading share" case decryptingShare = "Decrypting share" case parsingShare = "Parsing share" case generatingShare = "Generating share" case storingShare = "Storing share" case done = "Done" ``` ### Generate Solana Wallet Status Flow ```swift theme={null} case generatingShare = "Generating share" case parsingShare = "Parsing share" case storingShare = "Storing share" case done = "Done" ``` # Noah virtual accounts and payouts Source: https://docs.portalhq.io/sdks/ios/guide/noah Use portal.ramps.noah in the iOS SDK for Noah KYC, payins, payouts, and quotes through the Portal Client API. The iOS SDK exposes [Noah](/integrations/On-Off-Ramp/noah) virtual accounts and global payouts through `portal.ramps.noah`. Each method issues HTTP requests through `portal.api` to Portal's Noah integration on the [Client API](/apis/quickstart) using your client API key. You do not call Noah's servers directly from the app. For dashboard setup, signing keys, and supported CAIP-2 networks, see [Noah integration overview](/integrations/On-Off-Ramp/noah). For HTTP shapes and webhooks, see the [Noah workflow guides](/integrations/On-Off-Ramp/noah#workflow-guides) and [Noah Business API / EMM documentation](https://docs.noah.com/). ## Prerequisites * An [initialized `Portal` client](./getting-started) with a wallet and API access. * Noah enabled for your Portal environment and [configured in the dashboard](/integrations/On-Off-Ramp/noah). * For payins and payouts, the end user must complete [Noah KYC](/integrations/On-Off-Ramp/noah-kyc) with approved status before those flows succeed. ## Architecture | Layer | Role | | ----------------- | ---------------------------------------------------------------------------------------------------------------- | | Your app | Calls `portal.ramps.noah.*` | | iOS SDK | The `Noah` type at `portal.ramps.noah` issues authenticated HTTP requests through `portal.api` to the Portal API | | Portal API | `POST/GET …/api/v3/clients/me/integrations/noah/...` with the Portal client API key | | Noah (via Portal) | Hosted KYC, banking rails, settlement | Prefer `portal.ramps.noah` over lower-level APIs. All request and response types are exported from `PortalSwift`. ## Supported networks Every `network` parameter takes a CAIP-2 chain identifier. Use the constants on `NoahNetwork` rather than string literals so a typo is a compile error instead of a runtime failure. | Constant | CAIP-2 value | Noah network | | ----------------------------- | ----------------------------------------- | --------------------- | | `NoahNetwork.ethereum` | `eip155:1` | `Ethereum` | | `NoahNetwork.ethereumSepolia` | `eip155:11155111` | `EthereumTestSepolia` | | `NoahNetwork.base` | `eip155:8453` | `Base` | | `NoahNetwork.baseSepolia` | `eip155:84532` | `BaseTestSepolia` | | `NoahNetwork.polygon` | `eip155:137` | `PolygonPos` | | `NoahNetwork.polygonAmoy` | `eip155:80002` | `PolygonTestAmoy` | | `NoahNetwork.gnosis` | `eip155:100` | `Gnosis` | | `NoahNetwork.gnosisChiado` | `eip155:10200` | `GnosisTestChiado` | | `NoahNetwork.solana` | `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` | `Solana` | | `NoahNetwork.solanaDevnet` | `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` | `SolanaDevnet` | Any other value is rejected with `Unsupported network for Noah integration`. A non-CAIP-2 string such as `"ethereum"` is rejected earlier still, with `Network must be a "[namespace]:[reference]"`. Testnet networks pair with sandbox-only test assets such as `USDC_TEST`. ## Types and responses Successful Client API responses use an envelope `{ data: T, metadata: NoahResponseMetadata? }`, where `NoahResponseMetadata` is a type alias for `[String: AnyCodable]`. For example, `NoahInitiateKycResponse` is `{ data: { hostedUrl: String } }`. All nine methods are `async throws`. Failures surface as thrown errors — network problems, TLS issues, or an API error payload — and are handled with `do/catch` like any other async Portal call. See [Error handling](#error-handling). ## initiateKyc Starts hosted Noah onboarding. Open `data.hostedUrl` in the system browser. Validate **HTTPS** and the **hostname** against the checkout domains Noah documents for your environment (extend the example allowlist accordingly). ```swift theme={null} import PortalSwift import UIKit let portal = try Portal( "CLIENT_API_KEY_OR_CLIENT_SESSION_TOKEN", withRpcConfig: [ "eip155:1": "https://api.portalhq.io/rpc/v1/eip155/1", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": "https://api.devnet.solana.com", ] ) let allowedHosts: Set = [ "checkout.noah.com", "checkout.sandbox.noah.com", "staging-checkout.noah.com", ] do { let response = try await portal.ramps.noah.initiateKyc( request: NoahInitiateKycRequest( returnUrl: "https://yourapp.example/noah/return", fiatOptions: [NoahFiatOption(fiatCurrencyCode: "USD")], customerType: .individual ) ) if let url = URL(string: response.data.hostedUrl), url.scheme == "https", let host = url.host, allowedHosts.contains(host) { await UIApplication.shared.open(url) } else { // Handle this however your app prefers — surface it, log it, or fail the flow. print("Noah returned an unexpected KYC URL: \(response.data.hostedUrl)") } } catch { print("initiateKyc failed: \(error)") } ``` **Signature** ```swift theme={null} public func initiateKyc(request: NoahInitiateKycRequest) async throws -> NoahInitiateKycResponse ``` | Parameter | Type | Required | Description | | ---------------------- | ----------------------- | -------- | ------------------------------------------------------- | | `request.returnUrl` | `String` | Yes | HTTPS URL where Noah returns the user after onboarding. | | `request.fiatOptions` | `[NoahFiatOption]?` | No | Fiat currencies to present in onboarding. | | `request.customerType` | `NoahCustomerType?` | No | Onboarding flow variant — `.individual` or `.business`. | | `request.metadata` | `[String: AnyCodable]?` | No | Opaque metadata forwarded per API rules. | | `request.form` | `[String: AnyCodable]?` | No | Optional prefill payload for hosted forms. | The `returnUrl` must be an HTTPS URL. Custom app schemes (for example `myapp://callback`) are not supported. For mobile applications, the recommended pattern is to use an HTTPS bridge page that redirects to a deep link after KYC completion. **Returns** — `NoahInitiateKycResponse`: `{ data: { hostedUrl: String } }`. This endpoint is **idempotent**. If a Noah customer record already exists for the client, the previously stored `hostedUrl` is returned regardless of KYC status (`Pending`, `Submitted`, `Approved`, `Declined`). Calling it a second time does not restart onboarding and does not mint a fresh URL. This call only starts onboarding; KYC outcome and status changes arrive asynchronously via Noah **`Customer`** webhooks. See [Noah webhooks](/integrations/On-Off-Ramp/noah-webhooks). See also: [Noah KYC guide](/integrations/On-Off-Ramp/noah-kyc), [Noah hosted flows](https://docs.noah.com/). ## initiatePayin Creates a fiat-to-stablecoin payin and returns bank instructions and a `payinId`. Use a [supported network](#supported-networks) and the user's wallet address as `destinationAddress`. ```swift theme={null} let response = try await portal.ramps.noah.initiatePayin( request: NoahInitiatePayinRequest( fiatCurrency: "USD", cryptoCurrency: "USDC_TEST", network: NoahNetwork.solanaDevnet, destinationAddress: "SoLAddr1111111111111111111111111111111111111" ) ) print(response.data.payinId) print(response.data.bankDetails.accountNumber) ``` **Signature** ```swift theme={null} public func initiatePayin(request: NoahInitiatePayinRequest) async throws -> NoahInitiatePayinResponse ``` | Parameter | Type | Required | Description | | ---------------------------- | ---------------------------- | -------- | ----------------------------------------------------------------------------- | | `request.fiatCurrency` | `String` | Yes | Fiat currency code (for example `USD`). | | `request.cryptoCurrency` | `String` | Yes | Noah crypto asset code (for example stablecoin test symbols in sandbox). | | `request.network` | `String` | Yes | CAIP-2 chain identifier. Use a [`NoahNetwork` constant](#supported-networks). | | `request.destinationAddress` | `String` | Yes | Address that receives crypto after settlement. | | `request.businessFees` | `[String: NoahBusinessFee]?` | No | Custom business fee overrides keyed by payment method type. | **Returns** — `NoahInitiatePayinResponse`: `{ data: { payinId: String, bankDetails: NoahBankDetails } }`. `NoahBankDetails` includes: * `paymentMethodId` — payment method identifier * `paymentMethodType` — payment rail type, for example `BankSepa` or `IdentifierPix` * `accountNumber` — bank account number * `cryptoCurrency` — crypto currency for this payin * `network` — network identifier * `fee` — fee breakdown (`NoahFeeDetails`: `fiatCurrencyCode`, `totalFeePct`, `totalFeeBase`, `totalFeeMin`) * `accountHolderName` — optional account holder name * `bankCode` — optional bank routing or sort code * `bankName` — optional bank name * `bankAddress` — optional `NoahBankAddress` with `street`, `street2`, `city`, `postCode`, `state`, `country` * `reference` — optional payment reference * `relatedPaymentMethods` — optional `[NoahBankToAddressRelatedPaymentMethod]` Payin lifecycle updates are asynchronous; track them with Noah **`FiatDeposit`** and **`Transaction`** webhooks, not by polling this SDK response. See [Noah webhooks](/integrations/On-Off-Ramp/noah-webhooks). See also: [Payins](/integrations/On-Off-Ramp/noah-payins), [FiatDeposit webhooks](https://docs.noah.com/api-concepts/webhooks/fiat-deposits/). ## simulatePayin Simulates a fiat deposit against a payment method so you can exercise the payin flow end to end without moving real money. ```swift theme={null} let response = try await portal.ramps.noah.simulatePayin( request: NoahSimulatePayinRequest( paymentMethodId: "pm-1", fiatAmount: "10", fiatCurrency: "USD" ) ) print(response.data.fiatDepositId) ``` **Signature** ```swift theme={null} public func simulatePayin(request: NoahSimulatePayinRequest) async throws -> NoahSimulatePayinResponse ``` | Parameter | Type | Required | Description | | ------------------------- | -------- | -------- | ------------------------------------ | | `request.paymentMethodId` | `String` | Yes | Payment method identifier from Noah. | | `request.fiatAmount` | `String` | Yes | Fiat amount as a decimal string. | | `request.fiatCurrency` | `String` | Yes | Fiat currency code. | **Returns** — `NoahSimulatePayinResponse`: `{ data: { fiatDepositId: String, reference: String? } }`. This endpoint is **sandbox-only**. Calling it against a production environment fails with a `400` client error — `Simulate fiat deposit is only available in sandbox environments`. Guard the call behind your own environment check rather than shipping it in a production code path. ## getPayoutCountries Lists countries available for fiat payouts, keyed by country code with the supported fiat currency codes for each. ```swift theme={null} let response = try await portal.ramps.noah.getPayoutCountries() for (country, currencies) in response.data.countries { print("\(country): \(currencies.joined(separator: ", "))") } ``` **Signature** ```swift theme={null} public func getPayoutCountries() async throws -> NoahGetPayoutCountriesResponse ``` **Returns** — `NoahGetPayoutCountriesResponse`: `{ data: { countries: [String: [String]] } }`. ## getPayoutChannels Returns payout rails for a given crypto asset. Only `cryptoCurrency` is required; `country` and `fiatCurrency` narrow the results, and `fiatAmount` refines channel availability. ```swift theme={null} let response = try await portal.ramps.noah.getPayoutChannels( request: NoahGetPayoutChannelsRequest( cryptoCurrency: "USDC_TEST", country: "US", fiatCurrency: "USD", fiatAmount: "10", pageSize: 10 ) ) for channel in response.data.items { print(channel.id, channel.paymentMethodType, channel.rate) } ``` **Signature** ```swift theme={null} public func getPayoutChannels(request: NoahGetPayoutChannelsRequest) async throws -> NoahGetPayoutChannelsResponse ``` | Parameter | Type | Required | Description | | ------------------------- | --------- | -------- | ---------------------------------------------------------- | | `request.cryptoCurrency` | `String` | Yes | Crypto asset code for the payout leg. | | `request.country` | `String?` | No | ISO country code (for example `US`). | | `request.fiatCurrency` | `String?` | No | Fiat currency for the payout. | | `request.fiatAmount` | `String?` | No | Amount string used for filtering or quotes. | | `request.paymentMethodId` | `String?` | No | Filter to channels compatible with a saved payment method. | | `request.pageSize` | `Int?` | No | Page size between 1 and 100, validated server-side. | | `request.pageToken` | `String?` | No | Pagination token returned from a previous response. | **Returns** — `NoahGetPayoutChannelsResponse`: `{ data: { items: [NoahChannel], pageToken: String? } }`. Each `NoahChannel` includes: * `id` — channel identifier * `paymentMethodCategory` — broad grouping such as `Bank`, `Card`, or `Identifier` * `paymentMethodType` — payment rail type, for example `BankSepa` or `IdentifierPix` * `fiatCurrency` — fiat currency code * `country` — ISO country code * `limits` — `NoahChannelLimits` with `minLimit` and optional `maxLimit` * `rate` — exchange rate as a string * `processingSeconds` — estimated settlement time * `calculated` — optional `NoahChannelCalculated` with `totalFee` * `paymentMethods` — optional `[NoahChannelPaymentMethodDisplay]`, only populated when a customer id was supplied * `processingTier` — optional settlement speed tier such as `Standard` or `Priority` * `formSchema` — optional inline JSON Schema for the channel's payout form * `formMetadata` — optional `NoahFormMetadata` with `contentHash` * `issuer` — optional issuer identifier When a channel already carries a `formSchema`, use it directly instead of calling `getPayoutChannelForm` — it saves a round-trip. See also: [Payouts](/integrations/On-Off-Ramp/noah-payouts). ## getPayoutChannelForm Loads the dynamic form schema for a channel so you can collect recipient fields before requesting a quote. The `channelId` is percent-encoded as a single path segment. ```swift theme={null} let response = try await portal.ramps.noah.getPayoutChannelForm(channelId: "ch-1") // Render form fields from `response.data.formSchema` per Noah's schema print(response.data.formMetadata?.contentHash ?? "no schema metadata") ``` **Signature** ```swift theme={null} public func getPayoutChannelForm(channelId: String) async throws -> NoahGetPayoutChannelFormResponse ``` | Parameter | Type | Required | Description | | ----------- | -------- | -------- | -------------------------------------------- | | `channelId` | `String` | Yes | Channel identifier from `getPayoutChannels`. | **Returns** — `NoahGetPayoutChannelFormResponse`: `{ data: { formSchema: [String: AnyCodable]?, formMetadata: NoahFormMetadata? } }`. ## getPayoutQuote Requests fees and crypto amount estimates for a payout. Include `form` when the channel requires recipient data. ```swift theme={null} let quote = try await portal.ramps.noah.getPayoutQuote( request: NoahGetPayoutQuoteRequest( channelId: "ch-1", cryptoCurrency: "USDC_TEST", fiatAmount: "10" ) ) print( quote.data.payoutId, quote.data.formSessionId, quote.data.cryptoAmountEstimate, quote.data.cryptoAuthorizedAmount, quote.data.totalFee ) ``` **Signature** ```swift theme={null} public func getPayoutQuote(request: NoahGetPayoutQuoteRequest) async throws -> NoahGetPayoutQuoteResponse ``` | Parameter | Type | Required | Description | | ------------------------- | ----------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `request.channelId` | `String` | Yes | Payout channel id. | | `request.cryptoCurrency` | `String` | Yes | Crypto asset for the quote. | | `request.fiatAmount` | `String` | One of `fiatAmount` or `cryptoAmount` | Fiat amount to receive, as a string. | | `request.cryptoAmount` | `String` | One of `fiatAmount` or `cryptoAmount` | Crypto amount to sell, as a string. | | `request.quoted` | `Bool?` | No | Request a signed, rate-locked quote. When `true`, the response includes a `quote` with `signedQuote` and `expiry`. | | `request.form` | `[String: AnyCodable]?` | No | Recipient fields from the channel form. | | `request.fiatCurrency` | `String?` | No | Fiat currency override when needed. | | `request.paymentMethodId` | `String?` | No | Payment method hint when applicable. | | `request.formSessionId` | `String?` | No | Existing form session to continue, for multi-step forms. | | `request.businessFee` | `NoahBusinessFee?` | No | Custom business fee override for this quote. | `fiatAmount` and `cryptoAmount` are mutually exclusive, and `NoahGetPayoutQuoteRequest` enforces that in the type system rather than at runtime: there are two initializers, one taking `fiatAmount` and one taking `cryptoAmount`. Choosing an initializer chooses the denomination, and passing both or neither will not compile. ```swift theme={null} // Quote a fiat amount to receive NoahGetPayoutQuoteRequest(channelId: "ch-1", cryptoCurrency: "USDC_TEST", fiatAmount: "10") // Quote a crypto amount to sell NoahGetPayoutQuoteRequest(channelId: "ch-1", cryptoCurrency: "USDC_TEST", cryptoAmount: "10.5") ``` **Returns** — `NoahGetPayoutQuoteResponse`: includes `payoutId`, `totalFee`, `cryptoAmountEstimate`, `cryptoAuthorizedAmount`, `formSessionId`, and optional `cryptoCurrency`, `fiatCurrency`, `fiatAmount`, `rate`, `breakdown`, `quote` (`{ signedQuote, expiry }`), and `nextStep`. Each `breakdown` entry is a `NoahTransactionBreakdownItem` with a `type` of `ChannelFee`, `BusinessFee`, or `Remaining`, plus an `amount`. Set `quoted: true` if you intend to submit the payout with a `NoahQuotedOnchainDepositSourceTriggerInput` trigger — that variant requires the `signedQuote` returned here. ## initiatePayout Executes a payout after quoting. Returns the destination address and the on-chain deposit conditions your transfer must satisfy. ```swift theme={null} let formatter = ISO8601DateFormatter() let expiry = formatter.string(from: Date().addingTimeInterval(24 * 60 * 60)) // Use a stable nonce per payout attempt and reuse it on retries (max 36 characters) let nonce = String(UUID().uuidString.prefix(36)) let response = try await portal.ramps.noah.initiatePayout( request: NoahInitiatePayoutRequest( payoutId: quote.data.payoutId, sourceAddress: "SoLAddr1111111111111111111111111111111111111", expiry: expiry, nonce: nonce, network: NoahNetwork.solanaDevnet ) ) print(response.data.destinationAddress ?? "no destination address") print(response.data.conditions ?? []) ``` **Signature** ```swift theme={null} public func initiatePayout(request: NoahInitiatePayoutRequest) async throws -> NoahInitiatePayoutResponse ``` | Parameter | Type | Required | Description | | ----------------------- | ---------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------- | | `request.payoutId` | `String` | Yes | Identifier from `getPayoutQuote`. | | `request.sourceAddress` | `String` | Yes | Address funding the crypto leg. | | `request.expiry` | `String` | Yes | ISO-8601 expiry for the deposit authorization. | | `request.nonce` | `String` | Yes | Stable nonce for this payout attempt; **reuse on retry** so repeated calls stay idempotent. Must be 36 characters or fewer. | | `request.network` | `String` | Yes | CAIP-2 network for the deposit leg. Use a [`NoahNetwork` constant](#supported-networks). | | `request.trigger` | `NoahOnchainDepositSourceTrigger?` | No | Explicit on-chain deposit trigger. See [Deposit source triggers](#deposit-source-triggers). | | `request.businessFee` | `NoahBusinessFee?` | No | Custom business fee override for this payout. | **Returns** — `NoahInitiatePayoutResponse`: `{ data: { destinationAddress: String?, conditions: [NoahDepositSourceTriggerCondition]?, ruleId: String? } }`. `destinationAddress` is optional because it is derived from `conditions[0].destinationAddress` and falls back to `nil` when that shape is missing. `ruleId` is returned for permanent and quoted triggers. ### Deposit source triggers The payout must be authorized by a trigger. Either supply one explicitly, or rely on a saved payment method attached to the quote via `paymentMethodId` — when `trigger` is omitted and the saved method is a single on-chain deposit source, a default trigger is synthesised from the `sourceAddress`, `expiry`, and `nonce` you passed. `NoahOnchainDepositSourceTrigger` is an enum with three cases: ```swift theme={null} public enum NoahOnchainDepositSourceTrigger: Codable { case single(NoahSingleOnchainDepositSourceTriggerInput) case permanent(NoahPermanentOnchainDepositSourceTriggerInput) case quoted(NoahQuotedOnchainDepositSourceTriggerInput) } ``` | Case | Use it for | | ------------ | ----------------------------------------------------------------------------------------------------------------------- | | `.single` | One payout matching specific amount conditions on a network. | | `.permanent` | A source that stays active across multiple deposits until expiry. Set `networkAgnostic` to match any supported network. | | `.quoted` | A rate-locked payout. Requires the `signedQuote` from a `getPayoutQuote` call made with `quoted: true`. | The wire format is discriminated by a `Type` field, so the enum encodes its wrapped value **transparently** — no extra nesting is added to the JSON. Each trigger input defaults its `type` property to the correct discriminator, so you do not need to set it: ```swift theme={null} let trigger = NoahOnchainDepositSourceTrigger.single( NoahSingleOnchainDepositSourceTriggerInput( conditions: [ NoahSingleOnchainDepositSourceTriggerCondition( amountConditions: [ NoahSingleOnchainDepositSourceTriggerAmountCondition( comparisonOperator: .eq, value: "10.5" ) ], network: NoahNetwork.solanaDevnet ) ], sourceAddress: "SoLAddr1111111111111111111111111111111111111", expiry: expiry, nonce: nonce ) ) ``` Trigger inputs serialize their fields in PascalCase (`Type`, `Conditions`, `SourceAddress`, `Expiry`, `Nonce`) to match the Noah API wire format. After this call returns `destinationAddress` and `conditions`, submit the on-chain transfer to satisfy them. You can do this with any wallet — including Portal's own [send method](./send-tokens) (`portal.sendAsset(...)`) on the same `Portal` instance, which builds, signs, and broadcasts in one call. This call initiates the payout flow; completion and failures are reported asynchronously via Noah **`Transaction`** webhooks. See [Noah webhooks](/integrations/On-Off-Ramp/noah-webhooks). See also: [Transaction events](https://docs.noah.com/api-concepts/webhooks/transactions/), [automated payout recipes](https://docs.noah.com/recipes/payout/automated-payouts). ## getPaymentMethods Returns payment methods available to the customer, including a pagination token when more results are available. ```swift theme={null} let response = try await portal.ramps.noah.getPaymentMethods( request: NoahGetPaymentMethodsRequest( pageSize: 10, capability: .payoutTo ) ) print(response.data.paymentMethods.count, response.data.pageToken ?? "no more pages") ``` Call the no-argument overload to use server-side defaults: ```swift theme={null} let response = try await portal.ramps.noah.getPaymentMethods() ``` **Signature** ```swift theme={null} public func getPaymentMethods(request: NoahGetPaymentMethodsRequest) async throws -> NoahGetPaymentMethodsResponse ``` | Parameter | Type | Required | Description | | -------------------- | ------------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `request.pageSize` | `Int?` | No | Maximum number of payment methods to return. Must be an integer between 1 and 100 — validated server-side, and out-of-range values are rejected with a `400` rather than clamped. | | `request.pageToken` | `String?` | No | Cursor from a previous response's `data.pageToken`. | | `request.capability` | `NoahPaymentMethodCapability?` | No | Filter by capability — `.payoutFrom`, `.payinTo`, or `.payoutTo`. | **Returns** — `NoahGetPaymentMethodsResponse`: `{ data: { paymentMethods: [NoahPaymentMethod], pageToken: String? } }`. Each `NoahPaymentMethod` includes `id`, `paymentMethodCategory`, `country`, and `displayDetails`, plus optional `customerId`, `capabilities`, `accountHolderDetails`, and `issuerDetails`. ## Testing and mocking `Ramps.noah` is a `var` typed as `NoahProtocol`, so you can substitute your own conformer in tests: ```swift theme={null} final class MockNoah: NoahProtocol { func initiateKyc(request: NoahInitiateKycRequest) async throws -> NoahInitiateKycResponse { NoahInitiateKycResponse(data: NoahInitiateKycData(hostedUrl: "https://checkout.sandbox.noah.com/session")) } // … implement the remaining methods } portal.ramps.noah = MockNoah() ``` `Ramps.init(api:)` is internal, so replace the `noah` property rather than the `Ramps` instance. `Noah.init(api: PortalNoahApiProtocol)` and `PortalNoahApi.init(apiKey:apiHost:requests:)` are both public, so you can also stay on the real domain type and swap the transport layer instead: ```swift theme={null} portal.ramps.noah = Noah(api: MockNoahApi()) ``` ## Error handling All Noah methods are async throwing functions. Wrap calls in a `do/catch` block. Log or surface errors without printing full API responses in production if they might contain sensitive identifiers. Retry only idempotent reads unless your product team confirms otherwise. The following errors may be thrown: | Error | Description | | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `PortalRequestsError.unauthorized` | Authentication failed (401). Verify your Portal client is properly initialized and that Noah is enabled for your environment. | | `PortalRequestsError.clientError` | Client error (4xx). The request was invalid, or the operation is not permitted — for example calling `simulatePayin` in production. | | `PortalRequestsError.internalServerError` | Server error (5xx). A server-side issue occurred. | | `PortalRequestsError.couldNotParseHttpResponse` | The response could not be parsed. | | `URLError.badURL` | The request URL could not be built. | | `PortalApiError.unableToEncodeData` | A `channelId` passed to `getPayoutChannelForm` could not be percent-encoded. | ```swift theme={null} do { let response = try await portal.ramps.noah.getPayoutCountries() print(response.data.countries) } catch PortalRequestsError.unauthorized { print("Check your Portal client API key and Noah configuration") } catch { print("getPayoutCountries failed: \(error)") } ``` ## Related documentation * iOS SDK reference — [`initiateKyc`](../reference/noahinitiatekyc), [`initiatePayin`](../reference/noahinitiatepayin), [`simulatePayin`](../reference/noahsimulatepayin), [`getPayoutCountries`](../reference/noahgetpayoutcountries), [`getPayoutChannels`](../reference/noahgetpayoutchannels), [`getPayoutChannelForm`](../reference/noahgetpayoutchannelform), [`getPayoutQuote`](../reference/noahgetpayoutquote), [`initiatePayout`](../reference/noahinitiatepayout), [`getPaymentMethods`](../reference/noahgetpaymentmethods) * [Noah integration overview](/integrations/On-Off-Ramp/noah) * [KYC](/integrations/On-Off-Ramp/noah-kyc), [Payins](/integrations/On-Off-Ramp/noah-payins), [Payouts](/integrations/On-Off-Ramp/noah-payouts), [Webhooks](/integrations/On-Off-Ramp/noah-webhooks) * [Noah docs — API concepts](https://docs.noah.com/api-concepts/transactions/) * [Noah docs — authentication & signing](https://docs.noah.com/api-concepts/authentication/signing/) # Perform swaps (Deprecated) Source: https://docs.portalhq.io/sdks/ios/guide/perform-swaps This guide will walk you through how to perform swaps with Portal's iOS SDK. **Deprecated:** This integration has been deprecated. Please use our new trading integrations via `Portal.trading` for token swaps (see [0x](./zerox) or [Li.Fi](./lifi)). Please contact sales for additional pricing information on using the Swaps integrations at [partner@portalhq.io](mailto:partner@portalhq.io) The `PortalSwaps` class allows you to easily extend your existing `Portal` instance with swaps functionality. ## Initializing Swaps In order to initialize the Swaps class you need an existing `Portal` instance and a Swaps API Key. If you have not received a Swaps API Key, please contact support at [partner@portalhq.io](mailto:partner@portalhq.io). ```swift theme={null} var swaps = PortalSwaps( apiKey: "SWAPS-API-KEY", portal: portal ) ``` ## Fetching a list of Sources The `getSources()` function returns a dictionary containing valid, swappable token sources that can be used with your Portal MPC Wallet. The existing `Portal` instance that was provided at initialization is used to scope the request to the current chain. In order to retrieve this list, you can use the following code. ```swift theme={null} swaps.getSources() { result in var sources = result.data } ``` ### The returned list The dictionary returned by the `getSources()` function is a set of key/value pairs where the key is the name of the token and the value is the token address for that token. ## Fetching a swap Quote The `getQuote()` function returns a `Quote` object for a given set of arguments. ### QuoteArgs The minimum requirements for a valid set of `Quote` arguments are a `buyToken`, a `sellToken`, and either a `buyAmount` or a `sellAmount`. An example of a valid `QuoteArgs` object would look something like this. ```swift theme={null} var quoteArgs = QuoteArgs( buyToken: "UNI", // You can also use the token address instead, e.g. "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984" sellToken: "ETH", sellAmount: "10000", // Base units of the token ) ``` ### Using your QuoteArgs Once you have a valid `QuoteArgs` object, you can call the `getQuote()` function. ```swift theme={null} swaps.getQuote(args: quoteArgs) { result in var quote = result.data } ``` ### The Quote object The `Quote` object includes two properties: * `allowanceTarget` The address requiring allowances to complete the Swap * `cost` The total cost (gas fees) of the swap transaction * `transaction` The transaction to perform the swap ## Executing a Swap If you're selling ERC20 tokens, you'll need to utilize the `allowanceTarget` to give the associated contract permissions to access a given amount of the token you're selling. This requires a token allowance of at least what you're selling. If you're selling `ETH`, this step is not necessary. When you're ready to execute a swap, you can use the `request` function on your existing `Portal` instance to sign the transaction and make a request to the chain. ```swift theme={null} let requestResponse = try await portal.request( chainId: chainId, // CAIP-2 format (e.g. "eip155:10143" for Monad Testnet) method: .eth_sendTransaction, params: [transaction], options: RequestOptions(signatureApprovalMemo: "Execute swap") // Optional signature approval memo to use for the request ) ``` # Portal API methods Source: https://docs.portalhq.io/sdks/ios/guide/portal-api-methods Portal API's convenient helper methods are designed to streamline data retrieval from the Portal REST API. #### **`portal.getNFTAssets`** Fetches the asset balances (native and token balances) for a specified blockchain. It provides detailed information on the native balance and token balances held by a given address on the specified chain. ```swift theme={null} Task { do { let nfts = try await portal.getNftAssets(chainId) } catch { // Handle any errors. } } ``` **Parameters** * `chainId`: The chain identifier in CAIP-2 format (e.g., "eip155:1" for Ethereum mainnet, "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" for Solana mainnet)
Return type structure An array of `NftAsset` objects, each containing: * `nftID`: Unique identifier for the NFT * `name`: NFT name * `description`: NFT description * `imageURL`: URL of the NFT image * `chainID`: Blockchain identifier * `contractAddress`: NFT contract address * `tokenID`: Token identifier * `collection`: Collection information * `lastSale`: Last sale details containing: * `price`: Sale price * `currency`: Currency used * `date`: Sale date * `rarity`: Rarity information: * `rank`: Rarity rank * `score`: Rarity score * `floorPrice`: Current floor price information: * `price`: Floor price value * `currency`: Currency * `detailedInfo`: Extended NFT information including: * `ownerCount`: Number of owners * `tokenCount`: Total tokens * `createdDate`: Creation date * `attributes`: NFT attributes * `owners`: Ownership details * `extendedCollectionInfo`: Collection metadata * `extendedSaleInfo`: Sale history * `marketplaceInfo`: Marketplace listings * `mediaInfo`: Media preview URLs and metadata
#### **`portal.getAssets`** Retrieves a collection of assets (tokens and NFTs) for the specified blockchain. This method fetches all available assets associated with the wallet on the specified blockchain network, including both fungible tokens and NFTs. ```swift theme={null} Task { do { let nfts = try await portal.getAssets(chainId) } catch { // Handle any errors. } } ``` **Parameters** * `chainId`: The chain identifier in CAIP-2 format (e.g., "eip155:1" for Ethereum mainnet, "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" for Solana mainnet)
Return type structure An `AssetsResponse` object containing the following fields: * **nativeBalance**: A `NativeBalance` object representing the native balance of the wallet on the specified blockchain. Includes: * `balance`: The native balance as a string. * `decimals`: The number of decimals for the native token. * `name`: The name of the native token. * `rawBalance`: The raw balance as a string. * `symbol`: The symbol of the native token. * `metadata`: A `NativeBalanceMetadata` object containing: * `logo`: URL of the native token's logo. * `thumbnail`: URL of the native token's thumbnail. * **tokenBalances**: An array of `TokenBalanceResponse` objects representing the fungible token balances. Each object includes: * `balance`: The token balance as a string. * `decimals`: The number of decimals for the token. * `name`: The name of the token. * `rawBalance`: The raw balance as a string. * `symbol`: The symbol of the token. * `metadata`: A `TokenBalanceMetadata` object containing: * `tokenAddress`: The contract address of the token. * `verifiedContract`: A boolean indicating if the contract is verified. * `totalSupply`: The total supply of the token as a string. * `rawTotalSupply`: The raw total supply of the token as a string. * `percentageRelativeToTotalSupply`: The percentage of the wallet's balance relative to the total supply. * **nfts**: An array of `Nft` objects representing the non-fungible tokens (NFTs). Each object includes: * `nftID`: Unique identifier for the NFT. * `name`: The name of the NFT. * `description`: The description of the NFT. * `imageURL`: URL of the NFT image. * `chainID`: The blockchain identifier. * `contractAddress`: The contract address of the NFT. * `tokenID`: The token identifier. * `collection`: A `Collection` object containing collection information. * `lastSale`: A `LastSale` object containing details of the last sale: * `price`: The sale price. * `currency`: The currency used. * `date`: The date of the sale. * `rarity`: A `Rarity` object containing rarity information: * `rank`: The rarity rank. * `score`: The rarity score. * `floorPrice`: A `NftFloorPrice` object containing the current floor price: * `price`: The floor price value. * `currency`: The currency used. * `detailedInfo`: A `DetailedInfo` object containing extended NFT information: * `ownerCount`: The number of owners. * `tokenCount`: The total number of tokens. * `createdDate`: The creation date of the NFT. * `attributes`: An array of `Attribute` objects representing the NFT attributes. * `owners`: An array of `Owner` objects representing ownership details: * `ownerAddress`: The address of the owner. * `quantity`: The quantity of the NFT owned. * `firstAcquiredDate`: The date the NFT was first acquired. * `lastAcquiredDate`: The date the NFT was last acquired. * `extendedCollectionInfo`: An `ExtendedCollectionInfo` object containing collection metadata: * `bannerImageURL`: URL of the collection's banner image. * `externalURL`: External URL associated with the collection. * `twitterUsername`: Twitter username of the collection. * `discordURL`: Discord URL of the collection. * `instagramUsername`: Instagram username of the collection. * `mediumUsername`: Medium username of the collection. * `telegramURL`: Telegram URL of the collection. * `distinctOwnerCount`: The number of distinct owners in the collection. * `distinctNftCount`: The number of distinct NFTs in the collection. * `totalQuantity`: The total quantity of NFTs in the collection. * `extendedSaleInfo`: An `ExtendedSaleInfo` object containing sale history: * `fromAddress`: The address of the seller. * `toAddress`: The address of the buyer. * `priceUsdCents`: The sale price in USD cents. * `transaction`: The transaction hash. * `marketplaceID`: The ID of the marketplace. * `marketplaceName`: The name of the marketplace. * `marketplaceInfo`: An array of `MarketplaceInfo` objects containing marketplace listings: * `marketplaceID`: The ID of the marketplace. * `marketplaceName`: The name of the marketplace. * `marketplaceCollectionID`: The ID of the collection on the marketplace. * `nftURL`: URL of the NFT on the marketplace. * `collectionURL`: URL of the collection on the marketplace. * `verified`: A boolean indicating if the NFT is verified. * `floorPrice`: A `MarketplaceInfoFloorPrice` object containing the floor price: * `value`: The floor price value. * `paymentToken`: A `PaymentToken` object containing payment token details: * `paymentTokenID`: The ID of the payment token. * `name`: The name of the payment token. * `symbol`: The symbol of the payment token. * `address`: The address of the payment token. * `decimals`: The number of decimals for the payment token. * `valueUsdCents`: The floor price value in USD cents. * `mediaInfo`: A `MediaInfo` object containing media preview URLs and metadata: * `previews`: A `Previews` object containing preview URLs: * `imageSmallURL`: URL of the small preview image. * `imageMediumURL`: URL of the medium preview image. * `imageLargeURL`: URL of the large preview image. * `imageOpengraphURL`: URL of the OpenGraph preview image. * `blurhash`: The Blurhash string for the image. * `predominantColor`: The predominant color of the image. * `animationURL`: URL of the animation (if applicable). * `backgroundColor`: The background color of the media.
#### **`portal.getTransactions`** Fetches a list of the client's transaction history ordered by `blockTimestamp` descending (latest transactions will come first). This includes both inbound and outbound transactions. ```swift theme={null} Task { do { let transactions = try await portal.getTransactions(chainId) } catch { // Handle any errors. } } ``` **Parameters** * `chainId`: The chain identifier in CAIP-2 format (e.g., "eip155:1" for Ethereum mainnet, "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" for Solana mainnet) * `limit`: Optional maximum number of transactions to return. If nil, returns all transactions. * `offset`: Optional number of transactions to skip for pagination. If nil, starts from the beginning. * `order`: Optional `TransactionOrder` to specify the sort order of transactions: `ASC` or `DESC`.
Return type structure An array of `FetchedTransaction` objects, each containing: * `blockNum`: Block number in which the transaction was included * `uniqueId`: Unique identifier of the transaction * `hash`: Hash of the transaction * `from`: Address that initiated the transaction * `to`: Address that the transaction was sent to * `value`: Optional value transferred in the transaction as Float * `erc721TokenId`: Optional token ID for ERC721 NFT transactions * `erc1155Metadata`: Optional array of ERC1155 token metadata, each containing: * `tokenId`: Token identifier * `value`: Token value * `tokenId`: Optional general token identifier * `asset`: Optional type of asset involved (e.g., "ETH") * `category`: Category of the transaction (e.g., "external") * `rawContract`: Optional contract details containing: * `value`: Contract value * `address`: Contract address * `decimal`: Decimal representation of the contract value * `metadata`: Transaction metadata containing: * `blockTimestamp`: ISO format timestamp of the block * `chainId`: ID of the chain associated with the transaction
#### **`portal.getBalances`** Fetches a list of the client's ERC20 token balances. ```swift theme={null} Task { do { let erc20Balances = try await portal.getBalances(chainId) } catch { // Handle any errors. } } ``` **Parameters** * `chainId`: The chain identifier in CAIP-2 format (e.g., "eip155:1" for Ethereum mainnet, "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" for Solana mainnet)
Return type structure An array of `FetchedBalance` objects containing token balance information. Each object includes: * `contractAddress`: The contract address of the token * `balance`: The balance of the token as a string * `name`: Optional token name * `symbol`: Optional token symbol
#### **`portal.buildEip155Transaction`** Creates an unsigned eip155 transaction for transferring assets to another address on a specific chain. You can then use this unsigned eip155 transaction to sign and submit the eip155 transaction. ```swift theme={null} Task { do { let buildTransactionParam = BuildTransactionParam( to: "eip155 address", token: "ETH", amount: "1.0" ) let eip155Transaction = try await portal.buildEip155Transaction(chainId: chainId, params: buildTransactionParam) } catch { // Handle any errors. } } ``` **Parameters** 1. `chainId`: The chain identifier in CAIP-2 format (e.g., "eip155:1" for Ethereum mainnet) 2. `params`: A `BuildTransactionParam` object containing: * `to`: Recipient's address * `token`: Token identifier or contract address * `amount`: Amount to transfer as a string
Return type structure A `BuildEip115TransactionResponse` containing: * `transaction`: An `Eip115Transaction` object with: * `from`: Sender's address * `to`: Recipient's address * `data`: Optional transaction data for contract interactions * `value`: Optional transaction value in wei * `metadata`: A `BuildTransactionMetaData` object containing: * `amount`: Formatted transaction amount * `fromAddress`: Sender's address * `toAddress`: Recipient's address * `tokenAddress`: Optional token contract address for ERC20 transfers * `tokenDecimals`: Number of decimal places for the token * `tokenSymbol`: Optional token symbol * `rawAmount`: Raw transaction amount value * `error`: Optional error message if the build process encounters issues
#### **`portal.buildSolanaTransaction`** Creates an unsigned Solana transaction for transferring assets to another address on a specific chain. You can then use this unsigned Solana transaction to sign and submit the Solana transaction. ```swift theme={null} Task { do { let buildTransactionParam = BuildTransactionParam( to: "solana address", token: "SOL", amount: "1.0" ) let solanaTransaction = try await portal.buildSolanaTransaction(chainId: chainId, params: buildTransactionParam) } catch { // Handle any errors. } } ``` **Parameters** * `chainId`: The chain identifier in CAIP-2 format (e.g., "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" for Solana mainnet) * `params`: A `BuildTransactionParam` object containing: * `to`: Recipient's Solana address * `token`: Token identifier ("SOL" for native transfers, mint address for SPL tokens) * `amount`: Amount to transfer as a string (in lamports for SOL, raw amount for SPL tokens)
Return type structure A `BuildSolanaTransactionResponse` containing: * `transaction`: The serialized transaction string ready for signing * `metadata`: A `BuildTransactionMetaData` object containing: * `amount`: Formatted transaction amount * `fromAddress`: Sender's address * `toAddress`: Recipient's address * `tokenAddress`: Optional token mint address for SPL tokens * `tokenDecimals`: Number of decimal places for the token * `tokenSymbol`: Optional token symbol * `rawAmount`: Raw transaction amount value * `error`: Optional error message if the build process encounters issues
#### **`portal.getWalletCapabilities`** Retrieves the capabilities of the current wallet. This method fetches information about what features and operations are supported by the wallet, including supported chains and operations. ```swift theme={null} Task { do { let capabilities = try await portal.getWalletCapabilities() } catch { // Handle any errors. } } ```
Return type structure A `WalletCapabilitiesResponse` object, which is a dictionary where: * The **key** is a `String` representing a chain identifier (e.g., `"eip155:1"` for Ethereum mainnet). * The **value** is a `WalletCapabilitiesValue` object containing information about the wallet's capabilities for that chain. Each `WalletCapabilitiesValue` object includes: * **paymasterService**: A `PaymasterService` object indicating whether the wallet supports paymaster services for the specified chain. It contains: * `supported`: A boolean value (`true` or `false`) indicating whether paymaster services are supported.
#### **`portal.evaluateTransaction`** Evaluates a transaction for security and risk assessment, providing both validation and simulation results. This method can be used to assess the safety and potential risks of a transaction before execution. ```swift theme={null} Task { do { let evaluationResult = try await portal.evaluateTransaction( chainId: chainId, transaction: transaction, operationType: operationType ) } catch { // Handle any errors. } } ``` **Parameters** * `chainId`: The chain identifier in CAIP-2 format (e.g., `"eip155:1"` for Ethereum mainnet). * `transaction`: An `EvaluateTransactionParam` object containing the transaction details: * `to`:\* The destination address. * `value`: (Optional) The transaction value in wei. * `data`: (Optional) The transaction data for contract interactions. * `maxFeePerGas`: (Optional) The maximum total fee per gas unit. * `maxPriorityFeePerGas`: (Optional) The maximum priority fee per gas unit. * `gas`: (Optional) The gas limit. * `gasPrice`: (Optional) The gas price for legacy transactions. * `operationType`: Optional `EvaluateTransactionOperationType` to specify the type of evaluation: * `.validation`: Perform security validation only. * `.simulation`: Perform transaction simulation only. * `.all`: Perform both validation and simulation.
Return type structure A `BlockaidValidateTrxRes` object containing the following fields: * **validation**: Security validation results, including: * `classification`: The type of potential security issue. * `description`: A detailed description of the validation results. * `features`: An array of detected security features, each containing: * `type`: The type of feature. * `featureId`: A unique identifier for the feature. * `description`: A description of the feature. * `address`: The related address, if applicable. * `reason`: An explanation of the validation result. * `resultType`: The type of result. * `status`: The validation status. * **simulation**: Transaction simulation results, including: * `accountAddress`: The address being analyzed. * `accountSummary`: A summary of account state changes. * `addressDetails`: Detailed information about involved addresses. * `assetsDiffs`: Asset balance changes, containing: * `asset`: Information about the asset. * `in`: Details of incoming transfers. * `out`: Details of outgoing transfers. * `exposures`: Risk exposure analysis. * `status`: The simulation status. * `totalUsdDiff`: The total USD value change. * `totalUsdExposure`: The total USD value at risk. * `block`: The block number used for evaluation. * `chain`: The chain identifier.
**Related Documentation** * [getNftAssets function reference](../reference/getnftassets) * [getTransactions function reference](../reference/gettransactions) * [getBalances function reference](../reference/getbalances) * [buildEip155Transaction function reference](../reference/buildeip155transaction) * [buildSolanaTransaction function reference](../reference/buildsolanatransaction) * [getWalletCapabilities function reference](../reference/getwalletcapabilities) * [evaluateTransaction function reference](../reference/evaluatetransaction) # Recover a wallet Source: https://docs.portalhq.io/sdks/ios/guide/recover-a-wallet This guide will walk you through how to use your users' backups to recover their wallet. ## Portal-Managed Recovery (default) When using Portal-Managed Backups, you can simply call the `portal.recoverWallet()` function to generate new signing shares on your user's device. ```swift theme={null} // With iCloud await portal.recoverWallet(.iCloud) // With Password portal.setPassword("THE-USER-PASSWORD") await portal.recoverWallet(.Password) // With Google Drive await portal.recoverWallet(.GoogleDrive) // With Passkeys await portal.recoverWallet(.Passkey) // With Firebase Auth Backup (user must be signed in to Firebase) await portal.recoverWallet(.Firebase) ``` **WARNING**: To recover a wallet with the Portal SDK, your device must be configured to use passcode authentication. Please note that if you disable your passcode authentication after executing the `recover` function, you will need to run the `recover` function again. ## Self-Managed Recovery By default, Portal manages storing both the encrypted client backup share and the custodian backup share for you. If you prefer to store and manage the backup shares in your own infrastructure instead of using Portal-Managed Backups, see our Self-Managed Backups guide. Before recovering, you will need to retrieve the encrypted client backup share from your API. You will then provide the encrypted client backup share to `portal.recoverWallet`. Here's an example of how that might look in your code: ```swift theme={null} // The CipherText decoding object struct CipherTextResult: Codable { var cipherText: String } // Your API URL let yourApiUrl = "https://YOUR_API_URL.com" // The user id let userId = "THE-USER-ID" // Set the user's password. // Important note: this is only needed for the password BackupMethod try portal.setPassword("THE-USER-PASSWORD") // Obtain your API's URL for retrieving the encrypted user backup share. // TODO: - replace the `BackupMethods.Password.rawValue` with the proper backup method you are using guard let url = URL(string: "\(yourApiUrl)/users/\(userId)/encrypted-user-backup-shares?backupMethod=\(BackupMethods.Password.rawValue)") else { throw URLError(.badURL) } // Retrieve the encrypted user backup share on your API. let requests = PortalRequests() let yourApiResponse = try await requests.get(url) let decodedResponse = try decoder.decode(CipherTextResult.self, from: yourApiResponse) let encryptedUserBackupShare = decodedResponse.cipherText // TODO: - replace the `.Password` with the proper backup method you are using try await portal?.recoverWallet(.Password, withCipherText: encryptedUserBackupShare) { _ in // (Optional) Create a progress indicator here in the progress callback. } // ✅ The user has now recovered with their password successfully! ``` **WARNING**: To recover a wallet with the Portal SDK, your device must be configured to use passcode authentication. Please note that if you disable your passcode authentication after executing the `recover` function, you will need to run the `recover` function again. ## Progress Callbacks You can learn how to handle the progress callbacks for `portal.recoverWallet` here. ## Next steps Amazing! Your users can now easily recover their wallet. Next let's dive into handling sessions across multiple devices for your users. **Related Documentation** * [recoverWallet function reference](../reference/recoverwallet) * [Firebase Auth Backup option](../../../resources/backup-options/firebase-byo-auth) * [Backup options](../../../resources/backup-options/gdrive) * [Self-Managed Backups](../../../resources/self-managed-backups) * [MPC progress callbacks](./mpc-progress-callbacks) * [setPassword function reference](../reference/setpassword) * [isWalletBackedUp function reference](../reference/iswalletbackedup) # Request tracing Source: https://docs.portalhq.io/sdks/ios/guide/request-tracing Correlate Portal SDK requests with your own logs using the X-Portal-Trace-Id header. Starting in `7.3.0`, the iOS SDK attaches an `X-Portal-Trace-Id` header to every HTTP request it makes. Portal's logs are indexed by that value, so a single trace ID lets you — and Portal support — follow one user-facing action across every request it produced. ## Overview A trace ID is a lowercased UUID v4 sent as the `X-Portal-Trace-Id` request header. You do not have to do anything to get one: the SDK generates a trace ID for every request. What you gain by supplying your own is **correlation** — the same value appears in your logs and in Portal's, so a support ticket goes from "a signing request failed sometime Tuesday" to an exact record. ## Automatic tracing The header is added in three places inside the SDK, so every request path is covered: | Request path | Where the header is set | | -------------------- | -------------------------------------------------------------------------------------------------- | | `PortalAPIRequest` | `init(url:method:payload:bearerToken:traceId:)` seeds the header from `traceId`, or generates one. | | `PortalRequests` | `createBaseRequest` adds the header, and backfills it if a custom request omitted it. | | Legacy `HttpRequest` | Backfills the header when it is not already present. | iOS attaches the header **unconditionally** — there is no host allowlist. Requests to custom RPC endpoints you configure with `withRpcConfig` carry it too, because they are built with the same `PortalAPIRequest` type. The value is an opaque UUID and contains no client, wallet, or key data, but be aware that a third-party or self-hosted RPC provider will see it. This differs from the Android SDK, which filters the header to Portal-owned hosts. ## Supplying your own trace ID Three public entry points accept a trace ID. Wherever you leave it `nil`, the SDK generates one for you. ### Provider requests `RequestOptions` carries the trace ID for `portal.request(...)` and everything built on top of it: ```swift theme={null} public struct RequestOptions: Codable { public var signatureApprovalMemo: String? = nil public var sponsorGas: Bool? = nil public var traceId: String? = nil public init( signatureApprovalMemo: String? = nil, sponsorGas: Bool? = nil, traceId: String? = nil ) } ``` For requests that need a signature, `RequestOptions.traceId` is also forwarded into MPC signing metadata as `reqId`. That means the same value tags both the HTTP request and the MPC operation it triggers — one lookup covers the API call and the signing work behind it. That is the payoff, and it is not something you can reconstruct after the fact. ### Sending assets `SendAssetParams` gained a `traceId`: ```swift theme={null} public struct SendAssetParams: Codable { public let to: String public let amount: String public let token: String public let signatureApprovalMemo: String? public var sponsorGas: Bool? public var traceId: String? public init( to: String, amount: String, token: String, signatureApprovalMemo: String? = nil, sponsorGas: Bool? = nil, traceId: String? = nil ) } ``` ### Raw signing `rawSign` takes the trace ID as an argument. Unlike the other two, it is **not** defaulted — on this overload you must pass a value, even if that value is `nil`: ```swift theme={null} public func rawSign( message: String, chainId: String, signatureApprovalMemo: String? = nil, traceId: String? ) async throws -> PortalProviderResult ``` ```swift theme={null} import PortalSwift let traceId = generateTraceId() do { let response = try await portal.rawSign( message: "74657374", // Must be in hex format — "test" is "74657374" in hex. chainId: "eip155:11155111", signatureApprovalMemo: "Sign login challenge", traceId: traceId ) if let signature = response.result as? String { print("✅ Signed with trace ID \(traceId): \(signature)") } } catch { print("❌ Signing failed with trace ID \(traceId): \(error)") } ``` Existing calls keep compiling. `PortalProtocol` ships back-compatible extensions for `rawSign(message:chainId:)` and `rawSign(message:chainId:signatureApprovalMemo:)`, both of which pass `traceId: nil`. ## Multi-step flows share one ID When an SDK operation makes several requests, all of them carry the same trace ID. This is what makes tracing worth using — you get one handle for the whole flow instead of one per request. | Operation | Trace ID source | | ----------------------------------------------------------------------------------------------------- | --------------------------------------- | | `sendAsset` — build, sign, broadcast | `SendAssetParams.traceId`, or generated | | `portal.evmAccountType.upgradeTo7702` — status, build authorization list, raw sign, build transaction | Always generated internally | | MPC generate, backup, recover, eject | Always generated internally | MPC and EIP-7702 operations do **not** accept a trace ID. They generate one per operation and share it across their sub-requests, but you cannot supply your own. If you need to correlate an MPC flow with your own logs, see [Reading the ID back](#reading-the-id-back). Generate one trace ID per user-facing action — "send USDC", "sign in" — not per request. A trace ID that maps one-to-one onto HTTP requests tells you nothing you did not already know. ## Reading the id back `PortalMpcBackupResponse` gained a `traceId` property. It is the only place the SDK hands a generated trace ID back to you: ```swift theme={null} public struct PortalMpcBackupResponse { public let cipherText: String public let shareIds: [String] public let traceId: String public init(cipherText: String, shareIds: [String], traceId: String) } ``` That type is returned by `PortalMpcProtocol.backup(_:usingProgressCallback:)`. Note that `portal.backupWallet(_:usingProgressCallback:)` returns a `(cipherText:storageCallback:)` tuple and does **not** surface the trace ID — so if you go through `Portal` rather than `PortalMpc` directly, the backup trace ID is not available to you. No other response type exposes a trace ID. For every flow other than `PortalMpc.backup`, the way to know the trace ID is to supply it yourself. ## Helpers Two symbols are public: ```swift theme={null} /// The trace ID header used by connect-api (client + custodian APIs). public let PORTAL_TRACE_ID_HEADER = "X-Portal-Trace-Id" /// Generates a UUID v4 trace ID for request correlation. public func generateTraceId() -> String ``` Use `generateTraceId()` rather than rolling your own. It is the same generator the SDK uses internally, and it produces the lowercase UUID v4 format that Portal's logs expect. Generate the ID once, log it, then thread it through the whole action: ```swift theme={null} import PortalSwift let traceId = generateTraceId() print("Starting transfer — portalTraceId: \(traceId)") do { let response = try await portal.sendAsset( chainId: "eip155:11155111", params: SendAssetParams( to: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", amount: "0.01", token: "NATIVE", traceId: traceId ) ) print("Transfer submitted — portalTraceId: \(traceId), txHash: \(response.txHash)") } catch { print("Transfer failed — portalTraceId: \(traceId), error: \(error)") } ``` The same ID reaches `buildEip155Transaction`, the `eth_sendTransaction` provider request, and the MPC signing operation behind it. For a provider request, pass it through `RequestOptions`: ```swift theme={null} import PortalSwift let chainId = "eip155:11155111" let traceId = generateTraceId() do { let transactionParam = BuildTransactionParam( to: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", token: "USDC", amount: "1" ) let txDetails = try await portal.buildEip155Transaction( chainId: chainId, params: transactionParam ) let response = try await portal.request( chainId: chainId, method: .eth_sendTransaction, params: [txDetails.transaction], options: RequestOptions(signatureApprovalMemo: "Send USDC", traceId: traceId) ) guard let txHash = response.result as? String else { print("Unexpected response — portalTraceId: \(traceId)") return } print("✅ Transaction hash: \(txHash) — portalTraceId: \(traceId)") } catch { print("❌ Request failed — portalTraceId: \(traceId), error: \(error)") } ``` `portal.buildEip155Transaction(chainId:params:)` does not accept a trace ID, so in the example above the build request carries a generated one and only the `eth_sendTransaction` leg carries yours. If you want a single trace ID across build, sign, and broadcast, use `sendAsset` with `SendAssetParams(traceId:)` instead of building and requesting separately. See [Configure log level](./configure-log-level) if you also want the SDK's own output alongside your trace IDs. ## Sharing a trace id with Portal support Log the trace ID next to your own request identifier, so you can look up either from the other. When something fails, include the trace ID in your support ticket or bug report — it is the fastest path from a user complaint to the exact requests Portal handled. A trace ID is not a secret, but it is a correlation handle. Do not log it in a system that stores user PII under a different retention policy than your Portal logs — that turns a debugging aid into a way to join two datasets you meant to keep separate. ## Signature changes in 7.3.0 Threading `traceId` through the SDK changed several protocol requirements. If you **call** Portal's APIs, `7.3.0` requires no changes — every new parameter is either defaulted or paired with a back-compatible extension. If you **implement** any Portal protocol — most commonly a hand-rolled test mock — several gained new requirements. | Protocol | Change | | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PortalProtocol` | `rawSign(message:chainId:signatureApprovalMemo:traceId:)` — `traceId` is **not defaulted** on the requirement. Also gained `var ramps: Ramps { get }`, for [on/off-ramp](/integrations/On-Off-Ramp/noah) support. | | `PortalApiProtocol` | `traceId:` added to `eject`, `getClient`, `getClientCipherText`, `prepareEject`, `refreshClient`, `updateShareStatus`, `storeClientCipherText`, `getWalletCapabilities`, `buildEip155Transaction`, `buildSolanaTransaction`, `buildBitcoinP2wpkhTransaction`, and `broadcastBitcoinP2wpkhTransaction` — each with a back-compatible extension. Also gained `var noah: PortalNoahApiProtocol { get }` for [Noah](/integrations/On-Off-Ramp/noah), and `generatePreGeneratedShares(metadataStr:traceId:)`, used by the pre-generated wallet [feature flag](./feature-flags). | | `PortalEvmAccountTypeApiProtocol` | `traceId:` added to `getStatus`, `buildAuthorizationList`, and `buildAuthorizationTransaction`, each with a back-compatible extension. See [Upgrading to EIP-7702](./evm-account-type). | | `EvmAccountTypePortalDependency` | `rawSign(...)` gained `traceId: String?`. | | `PortalSignerProtocol` | `sign(...)` gained `reqId: String?`. | Two call-site changes are genuinely breaking: **`PortalApi.init` gained a parameter.** `enclaveMPCHost` was inserted as the **third** parameter, before `provider:`. It is defaulted, so labelled calls are unaffected — but if you construct `PortalApi` directly and pass arguments positionally, check your call site. ```swift theme={null} // 7.2.x let api = PortalApi( apiKey: "YOUR_CLIENT_API_KEY", apiHost: "api.portalhq.io", provider: provider ) // 7.3.0 let api = PortalApi( apiKey: "YOUR_CLIENT_API_KEY", apiHost: "api.portalhq.io", enclaveMPCHost: "mpc-client.portalhq.io", provider: provider ) ``` **`PortalMpcBackupResponse` gained a memberwise init.** `init(cipherText:shareIds:traceId:)` replaces the two-argument form. Test fixtures that build this type need the extra argument: ```swift theme={null} // 7.2.x let response = PortalMpcBackupResponse( cipherText: "cipher-text", shareIds: ["share-id"] ) // 7.3.0 let response = PortalMpcBackupResponse( cipherText: "cipher-text", shareIds: ["share-id"], traceId: generateTraceId() ) ``` ## Next Steps * Review the [generateTraceId reference](../reference/generatetraceid) * Learn about [signing transactions](./sign-a-transaction) * Read [Send tokens](./send-tokens) for the full `sendAsset` flow * Turn on SDK logging with [Configure log level](./configure-log-level) * Check [Troubleshooting tips](./troubleshooting-tips) before filing a ticket # Send tokens Source: https://docs.portalhq.io/sdks/ios/guide/send-tokens Here's exactly how you can send tokens from your Portal wallet to another address. ## Fund your Wallet Now that you have a wallet, the next step is to get test tokens for it. You can fund your wallet using **`portal.receiveTestnetAsset`**. If you are looking for a greater variety of test tokens, we recommend exploring our [faucets page](../../../resources/testnet-faucets). The `chainId` will need to be a [CAIP-2](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md) compliant Chain ID. For more info on Chain ID formatting, see [this doc](../../../resources/chain-id-formatting). ```swift theme={null} let chainId = "eip155:10143" // Monad Testnet let params = FundParams( amount: "0.01", // You will receive 0.01 MON token: "NATIVE" // Token, use "NATIVE" for the chain's native token ) // Fund your Portal wallet let response = portal.receiveTestnetAsset(chainId, params) print("✅ Transaction hash: \(response.data.txHash)") ``` ## Sending Tokens from your Wallet Portal provides two ways to send transactions: 1. **`portal.sendAsset()`** - A simple method for sending tokens from your Portal wallet. 2. **`portal.provider.request()`** - Direct access to the underlying web3 provider for custom transactions. (You can learn more about this method [here](./sign-a-transaction).) For most use cases, we recommend using **`portal.sendAsset()`** as shown in the examples below. ### Submitting an EVM Transaction ```swift theme={null} let chainId = "eip155:10143" // Monad Testnet let params = SendAssetParams( to: "0xDestinationAddress", // The recipient address amount: "0.0001", // Sends 0.0001 MON token: "NATIVE", // Token, use "NATIVE" for the chain's native token signatureApprovalMemo: "Send MON" // Optional signature approval memo to use for the request ) // Send the tokens let txHash = try await portal.sendAsset(chainId, params) print("✅ Transaction hash: \(txHash)") ``` The optional `signatureApprovalMemo` in `SendAssetParams` is shown to the user during the signing approval flow. The same memo is supported on `portal.request(...)` (via `RequestOptions(signatureApprovalMemo:)`) and `portal.rawSign(...)`. See [Sign a transaction](./sign-a-transaction#signature-approval-memo). **Account Abstraction clients:** `txHash` is a **UserOperation hash**, not an on-chain transaction hash — it will **not** resolve on a block explorer such as Etherscan or Monadscan. The on-chain transaction hash is only assigned once the bundler includes the UserOperation on-chain. Look up the UserOperation hash on a UserOp explorer such as [JiffyScan](https://jiffyscan.xyz/) to find the resulting transaction hash. See [Account abstraction](../../../resources/account-abstraction). ### Submitting a Solana Transaction You will need **`SOL`** to submit a Solana transaction, which is not currently supported by **`portal.receiveTestnetAsset`**. You can find a faucet to get test **`SOL`** tokens [here](../../../resources/testnet-faucets). ```swift theme={null} let chainId = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" // Solana Devnet let params = SendAssetParams( to: "0xDestinationAddress", // The recipient address amount: "0.0001", // Sends 0.0001 SOL token: "NATIVE", // Token, use "NATIVE" for the chain's native token signatureApprovalMemo: "Send SOL" // Optional signature approval memo to use for the request ) // Send the tokens let response = try await portal.sendAsset(chainId, params) print("✅ Transaction hash: \(response.result)") ``` You just sent your first token from your Portal wallet, that's awesome! 🎉 ## Advanced: Controlling Gas Sponsorship If your client is using [Account Abstraction](../../../resources/account-abstraction), you can control whether Portal sponsors the gas fees for each transaction using the `sponsorGas` parameter in `SendAssetParams`. ### Example: User Pays Gas ```swift theme={null} let chainId = "eip155:11155111" // Ethereum Sepolia let params = SendAssetParams( to: "0xDestinationAddress", amount: "0.0001", token: "NATIVE", sponsorGas: false // Portal client pays transaction fees ) // Send the tokens let txHash = try await portal.sendAsset(chainId, params) print("✅ Transaction hash: \(txHash)") ``` By setting `sponsorGas: false`, the Portal client will pay for the transaction fees instead of having them sponsored. This is useful for testing or when you want users to pay for specific operations. Omitting `sponsorGas` or setting it to `true` produces the same behavior - both will sponsor gas if your environment is configured for AA on that chain. Only `sponsorGas: false` changes the default behavior to disable sponsorship. Learn more about gas sponsorship control in the [Account Abstraction guide](../../../resources/account-abstraction#controlling-gas-sponsorship-per-transaction). You may have a more advanced use case than simply sending tokens from your Portal wallet. Next, we will dive into how to build your own transaction and also how to sign it (without submitting it). # Sign a transaction Source: https://docs.portalhq.io/sdks/ios/guide/sign-a-transaction This example shows how the Portal Provider interacts with the blockchain. This example shows how the **Portal Provider** interacts with **the blockchain**. ### Signing an ethereum transaction The **Provider** has a `address` of our **MPC wallet**. The **Provider** then proxies the request for balance to the configured RPC url and returns the result. Ensure you have set the gateway URL correctly with [Infura](https://www.infura.io/) or [Alchemy](https://www.alchemy.com/) when you initialize the portal class. Below is an example of how to use Portal to send a USDC transaction on the Monad Testnet network: ```swift theme={null} // Monad Testnet let chainId = "eip155:10143" // CAIP-2 format. // Define the transaction parameters let transactionParam = BuildTransactionParam( to: "0xDestinationAddress", // Replace with the recipient's address token: "USDC", // Token to send (USDC in this case) amount: "1" // Amount of USDC to send ) // Build the transaction using Portal let txDetails = try await portal.buildEip155Transaction( chainId: chainId, params: transactionParam ) // Sign and send the transaction let response = try await portal.request( chainId: chainId, method: .eth_sendTransaction, params: [txDetails.transaction], options: RequestOptions(signatureApprovalMemo: "Send USDC") // Optional signature approval memo to use for the request ) // Obtain the transaction hash. guard let txHash = response.result as? String else { // Handle a bad response here return } print("✅ Transaction hash: \(txHash)") ``` ### Signature approval memo You can pass an optional `signatureApprovalMemo` so the user sees a short description during the signing approval flow. It is supported across Portal's signing methods: * `portal.request(...)` — provide it via `RequestOptions(signatureApprovalMemo:)` on any signing method (`eth_sendTransaction`, `sol_signAndSendTransaction`, `eth_signUserOperation`, etc.). * `portal.sendAsset(chainId, params)` — include `signatureApprovalMemo` in `SendAssetParams`. See [Send tokens](./send-tokens). * `portal.rawSign(message:chainId:signatureApprovalMemo:)` — pass it directly as an argument. ### Controlling Gas Sponsorship If your client is using [Account Abstraction](../../../resources/account-abstraction), you can control whether Portal sponsors the gas fees using `RequestOptions`: ```swift theme={null} // Monad Testnet let chainId = "eip155:10143" // Define the transaction parameters let transactionParam = BuildTransactionParam( to: "0xDestinationAddress", token: "USDC", amount: "1" ) // Build the transaction using Portal let txDetails = try await portal.buildEip155Transaction( chainId: chainId, params: transactionParam ) // Sign and send with sponsorGas control let response = try await portal.request( chainId: chainId, method: .eth_sendTransaction, params: [txDetails.transaction], options: RequestOptions(sponsorGas: false) // User pays for gas ) guard let txHash = response.result as? String else { return } print("✅ Transaction hash: \(txHash)") ``` Omitting `RequestOptions` or setting `sponsorGas: true` produces the same behavior - both will sponsor gas if your environment is configured for AA on that chain. Only `sponsorGas: false` changes the default behavior to disable sponsorship. Learn more in the [Account Abstraction guide](../../../resources/account-abstraction#controlling-gas-sponsorship-per-transaction). This example demonstrates how to use **Portal's SDK** to send a transaction on the Monad Testnet network. The transaction involves sending **1 USDC** to a specified recipient address. Here's a breakdown of the process: 1. **Chain ID**: The transaction is configured for the Monad Testnet network using the CAIP-2 format (`eip155:10143`). 2. **Transaction Parameters**: The `BuildTransactionParam` struct is used to define the transaction details: * `to`: The recipient's address. * `token`: The token to send (in this case, `USDC`). * `amount`: The amount of the token to send (e.g., `1` USDC). 3. **Building the Transaction**: The `portal.buildEip155Transaction` method constructs the transaction object using the specified parameters. 4. **Signing and Sending**: The `portal.request` method is used to sign and send the transaction. The method takes the chain ID, the `eth_sendTransaction` method, and the transaction details as parameters. 5. **Transaction Hash**: Once the transaction is successfully sent, the transaction hash is returned and printed to the console. However, there's many other `PortalRequestMethod`s available for you to use, which [you can find in the SDK](https://github.com/portal-hq/portalswift). ### Signing a User Operation If your client uses [Account Abstraction](../../../resources/account-abstraction), you can sign an [ERC-4337 User Operation](https://eips.ethereum.org/EIPS/eip-4337) using the `eth_signUserOperation` method. This signs the User Operation without submitting it on-chain, returning the signature directly. ```swift theme={null} let chainId = "eip155:11155111" // Ethereum Sepolia guard let address = await portal.getAddress(chainId) else { throw PortalExampleAppError.addressNotFound() } let userOp: [String: String] = [ "sender": address, "nonce": "0x0", "callData": "0x", "callGasLimit": "0x5208", "verificationGasLimit": "0x5208", "preVerificationGas": "0x5208", "maxFeePerGas": "0x1", "maxPriorityFeePerGas": "0x1" ] let response = try await portal.request( chainId: chainId, method: .eth_signUserOperation, params: [AnyCodable(userOp)] ) guard let signature = response.result as? String else { return } print("✅ Signature: \(signature)") ``` **User Operation Parameters:** | Name | Type | Description | | ---------------------- | -------- | ----------------------------------------------------------------------------- | | `sender` | `String` | The address of the smart contract account | | `nonce` | `String` | Anti-replay parameter (hex-encoded) | | `callData` | `String` | The data to pass to the `sender` during the main execution call (hex-encoded) | | `callGasLimit` | `String` | Gas limit for the main execution call (hex-encoded) | | `verificationGasLimit` | `String` | Gas limit for the verification step (hex-encoded) | | `preVerificationGas` | `String` | Gas paid for pre-verification (hex-encoded) | | `maxFeePerGas` | `String` | Maximum fee per unit of gas (hex-encoded) | | `maxPriorityFeePerGas` | `String` | Maximum priority fee per unit of gas (hex-encoded) | You can also pass `RequestOptions` to control gas sponsorship or include a signature approval memo: ```swift theme={null} let response = try await portal.request( chainId: chainId, method: .eth_signUserOperation, params: [AnyCodable(userOp)], options: RequestOptions( signatureApprovalMemo: "Approve UserOp", sponsorGas: true ) ) ``` ## Estimating Gas By default, Portal will estimate and populate the `gas` property in a `transaction` object if the property is `undefined`. To estimate the `gas` value manually, you'll want to use the `eth_estimateGas` RPC call and pass in your transaction as the parameter. ```swift theme={null} // Monad Testnet let chainId = "eip155:10143" // CAIP-2 format. // Obtain the eip155 address of the user's wallet. guard let eip155Address = await portal.getAddress(chainId) else { throw PortalExampleAppError.addressNotFound() } // Create the transaction object. let transaction = [ "from": eip155Address, // Replace with the sender's address "to": "0xRecipientAddress", // Replace with the recipient's address "value": "0x10", // Native token amount to send (WEI for eip155:10143). ] // Make the eth_estimateGas request with the transaction object. let requestResponse = try await portal.request( chainId: chainId, method: .eth_estimateGas, params: [transaction] ) // Retrieve the estimated gas value. guard let estimatedGas = requestResponse.result as? String else { throw PortalExampleAppError.invalidResponseTypeForRequest() } // ✅ Nice! You just retrieved the gas estimate. ``` Here's a breakdown of the process: 1. **Chain ID**: The transaction is configured for the Monad Testnet network using the CAIP-2 format (`eip155:10143`). 2. **Sender Address**: The `portal.getAddress` method retrieves the user's wallet address for the specified chain. 3. **Transaction Object**: A transaction object is created with the following fields: * `from`: The sender's address. * `to`: The recipient's address. * `value`: The amount of native token (ETH) to send, specified in WEI. 4. **Gas Estimation**: The `portal.request` method is used to call the `eth_estimateGas` method, which estimates the gas required for the transaction. 5. **Result Handling**: The estimated gas value is retrieved from the response and printed to the console. ### Signing Solana Transactions To construct your own advanced transaction for solana simply follow that example: ```swift theme={null} // Solana Devnet let chainId = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" // CAIP-2 format. // Define the transaction parameters let transactionParam = BuildTransactionParam( to: "DestinationAddress", // Replace with the recipient's address token: "USDC", // Token to send (USDC in this case) amount: "1" // Amount of USDC to send ) // Build the transaction using Portal let txDetails = await portal.buildSolanaTransaction( chainId: chainId, params: transactionParam ) // Sign and send the transaction let response = try await portal.request( chainId: chainId, method: .sol_signAndSendTransaction, params: [txDetails.transaction], options: RequestOptions(signatureApprovalMemo: "Send USDC") // Optional signature approval memo to use for the request ) // Obtain the transaction hash. guard let txHash = response.result as? String else { // Handle a bad response here return } ​ print("✅ Transaction hash: \(txHash)") ``` This example demonstrates how to use **Portal's SDK** to send a transaction on the **Solana Devnet**. The transaction involves sending **1 USDC** to a specified recipient address. Here's a breakdown of the process: 1. **Chain ID**: The transaction is configured for the Solana Devnet using the CAIP-2 format (`solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`). 2. **Transaction Parameters**: The `BuildTransactionParam` struct is used to define the transaction details: * `to`: The recipient's address. * `token`: The token to send (in this case, `USDC`). * `amount`: The amount of the token to send (e.g., `1` USDC). 3. **Building the Transaction**: The `portal.buildSolanaTransaction` method constructs the transaction object using the specified parameters. 4. **Signing and Sending**: The `portal.request` method is used to sign and send the transaction. The method takes the chain ID, the `sol_signAndSendTransaction` method, and the transaction details as parameters. 5. **Transaction Hash**: Once the transaction is successfully sent, the transaction hash is returned and printed to the console. ### Raw sign
You can now utilize our SDK to generate raw signatures that are generated using the underlying key share without adding any chain specific formatting to the signature. This effectively unlocks your ability to use the Portal SDK with any chain that uses `SECP256K1` or `ED25519`. ```swift theme={null} do { let response = try await portal.rawSign( message: "74657374", // Must be in hex format, e.g. "test" is "74657374" in hex. chainId: "eip155:10143", // choose the chainId eip155 = secp256k1, solana = ed25519, monad = secp256k1 signatureApprovalMemo: "test" // Optional signature approval memo to use for the request ) if let signature = response.result as? String { print("✅ Successfully signed message: \(signature)") } } catch { print("❌ Error signing message: \(error)") } ``` ### Enabling the Enclave Signer Executing MPC operations requires computation on the client device. Depending on the CPU of the client device this can take variable amounts of time, leading to inconsistent signing times across users. To solve this, you can leverage the **Enclave MPC API** from your SDK to execute MPC operations server-side which leads to consistent (and often faster) signing speeds. This feature leverages the Enclave MPC API by sending the user's key share to a Trusted Execution Environment (TEE) which runs the MPC code in a secure AWS Nitro Enclave with the same non-custodial guarantees as client-side MPC. By enabling the `useEnclaveMpcApi` feature flag the client key share will be transmitted from the user device, but it is **never** stored. TEEs in Nitro Enclaves work by **encrypting memory** and **verifying execution.** Encrypted memory means that all of the data being processed on the enclave can’t be accessed by anything other than the running application. Portal employees can’t even read the data on there! Verified execution means that a user can cryptographically verify that their request was handled in a secure enclave. When a user sends an API request to the enclave, Portal returns a set of signed “measurements” that can be verified by the enclave’s public key to ensure that the request was processed on an AWS Nitro Enclave. To learn more check out our blog post introducing[ the Enclave MPC API](https://www.portalhq.io/post/introducing-the-enclave-mpc-api). Here’s how to enable it: ```swift theme={null} import PortalSwift // Initialize Portal with the Enclave MPC API enabled let portal = try Portal( "CLIENT_API_KEY_OR_CLIENT_SESSION_TOKEN", featureFlags: FeatureFlags( useEnclaveMPCApi: true ) ) ``` By setting `useEnclaveMPCApi` to `true`, the Portal instance will use the Enclave MPC API for signing transactions, ensuring faster computation and consistent performance across client devices. *** And now you are signing transactions with Portal! 🙌 🚀 Next, we'll explore how to simulate a transaction so that you can create smoother experiences for your users. **Related Documentation** * [buildEip155Transaction function reference](../reference/buildeip155transaction) * [buildSolanaTransaction function reference](../reference/buildsolanatransaction) * [sendSol function reference](../reference/sendsol) # Troubleshooting tips Source: https://docs.portalhq.io/sdks/ios/guide/troubleshooting-tips This guide will walk you through some tips and tricks to help you troubleshoot issues with the Portal SDK. ## Debugging Development Performance When developing and debugging on physical iOS devices with Portal SDK, you may experience slower performance compared to running without a debugger or on simulators. To optimize the debugging experience on physical iOS devices, add the following environment variable to your Xcode scheme: 1. Open your project in Xcode 2. Go to Product > Scheme > Edit Scheme 3. Select "Run" on the left sidebar 4. Go to the "Arguments" tab 5. In the "Environment Variables" section, click "+" 6. Add: * Name: `GODEBUG` * Value: `asyncpreemptoff=1` This optimization will significantly improve the debugging experience when using a debugger on physical devices. This environment variable only affects debugging sessions and has no impact on release builds or production performance. Your app will perform optimally in production whether or not you add this debugging configuration - it only helps improve the development experience when debugging on physical devices. # Earn with Yield.xyz Source: https://docs.portalhq.io/sdks/ios/guide/yield-xyz Learn how to discover, enter, manage, and exit yield opportunities. Portal's iOS SDK provides comprehensive yield opportunities capabilities through the `portal.yield.yieldxyz` API. This guide covers discovering yield opportunities, entering positions, managing existing positions, and exiting yield opportunities. ## Overview The yield functionality allows users to: * **Discover** available yield opportunities across different protocols and networks * **Enter** yield positions by depositing tokens into yield opportunities * **Manage** existing positions (claim rewards, voting, etc.) * **Exit** yield positions to withdraw aggregated tokens and rewards * **Track** yield balances and historical yield actions ## Prerequisites Before using yield operations, ensure you have: * A properly initialized Portal client * An active wallet with the required token(s) on the target network (see [Create a wallet](./create-a-wallet)) * Yield.xyz integration enabled in your Portal Dashboard (see [Yield.xyz Integration](/integrations/Yield/yield-xyz)) ## Discovering Yield Opportunities Use the `discover` method to find available yield opportunities. For complete API documentation, see the [Yield.xyz API reference](https://docs.yield.xyz/reference/yieldscontroller_getyields). ```swift theme={null} let request = YieldXyzGetYieldsRequest( offset: 0, limit: 10, network: "eip155:11155111" // Sepolia network // ... other parameters ) do { let response = try await portal.yield.yieldxyz.discover(request: request) if let rawResponse = response.data?.rawResponse { let yieldOpportunities = rawResponse.items // Process and display yield opportunities } } catch { // Handle error print("Error discovering yields: \(error)") } ``` Popular, high-quality USDC yield options with no lockups or limits: * USDC Aave V3 Lending: * `base-usdc-aave-v3-lending` * USDC Fluid Vault: * `base-usdc-fusdc-0xf42f5795d9ac7e9d757db633d693cd548cfd9169-4626-vault` * USDC Spark Savings Vault: * `ethereum-usdc-spusdc-0x28b3a8fb53b741a8fd78c0fb9a6b2393d896a43d-4626-vault` ## Entering Yield Positions To enter a yield position, first discover the specific yield, then use the `enter` method. For complete API documentation, see the [Yield.xyz enter yield reference](https://docs.yield.xyz/reference/actionscontroller_enteryield). For the example below, we will use the yield opportunity with the ID `"ethereum-sepolia-link-aave-v3-lending"`. Fund your Portal client with the required `LINK` token to enter the position. ```swift theme={null} do { let userAddress = try await portal.getAddress() let enterRequest = YieldXyzEnterRequest( yieldId: "ethereum-sepolia-link-aave-v3-lending", address: userAddress, arguments: YieldXyzEnterArguments( amount: "1" // 1 LINK token ) ) let enterResponse = try await portal.yield.yieldxyz.enter(request: enterRequest) if let rawResponse = enterResponse.data?.rawResponse { let transactions = rawResponse.transactions // Process transactions, this is described in the "Transaction Processing" section below try await processTransactions(transactions) } } catch { // Handle error print("Error entering yield position: \(error)") } ``` ## Checking Yield Balances Retrieve current yield positions and balances. For complete API documentation, see the [Yield.xyz get balances reference](https://docs.yield.xyz/reference/yieldscontroller_getaggregatebalances). ```swift theme={null} do { let userAddress = try await portal.getAddress() let balanceRequest = YieldXyzGetBalancesRequest( queries: [ YieldXyzBalanceQuery( address: userAddress, network: "eip155:11155111" // Sepolia testnet ) ] ) let response = try await portal.yield.yieldxyz.getBalances(request: balanceRequest) if let rawResponse = response.data?.rawResponse { let yieldPositions = rawResponse.items // Process and display yield positions information } } catch { // Handle error print("Error getting balances: \(error)") } ``` We recommend always specifying a `yieldId` on each balance query. When `yieldId` is provided, Yield.xyz can resolve balances directly, so you don't need to call the `track` endpoint after entering or exiting positions. ```swift theme={null} let balanceRequest = YieldXyzGetBalancesRequest( queries: [ YieldXyzBalanceQuery( address: userAddress, network: "eip155:11155111", yieldId: "ethereum-sepolia-link-aave-v3-lending" ) ] ) ``` ## Exiting Yield Positions Use the `exit` method to withdraw from yield positions. For complete API documentation, see the [Yield.xyz exit yield reference](https://docs.yield.xyz/reference/actionscontroller_exityield). ```swift theme={null} do { let userAddress = try await portal.getAddress() let exitRequest = YieldXyzExitRequest( yieldId: "ethereum-sepolia-link-aave-v3-lending", address: userAddress, arguments: YieldXyzEnterArguments(amount: "0.001") ) let exitResponse = try await portal.yield.yieldxyz.exit(request: exitRequest) if let rawResponse = exitResponse.data?.rawResponse { let transactions = rawResponse.transactions // Process transactions, this is described in the "Transaction Processing" section below try await processTransactions(transactions) } } catch { // Handle error print("Error exiting yield position: \(error)") } ``` ## High-Level Methods Use `deposit` and `withdraw` when you want one call for the full flow: resolve the yield, build the action, sign and send each transaction in order, wait for confirmation between steps, and report each hash back to Yield.xyz. Both are `async throws` and take the same parameter shape — `YieldWithdrawParams` and `YieldWithdrawResult` are type aliases for the deposit types. ### Signatures ```swift theme={null} func deposit(params: YieldDepositParams, options: YieldSubmitOptions?) async throws -> YieldDepositResult func withdraw(params: YieldWithdrawParams, options: YieldSubmitOptions?) async throws -> YieldWithdrawResult ``` Convenience overloads drop the options argument entirely: ```swift theme={null} func deposit(params: YieldDepositParams) async throws -> YieldDepositResult func withdraw(params: YieldWithdrawParams) async throws -> YieldWithdrawResult ``` ### Essential parameters `YieldDepositParams`: | Parameter | Type | Required | Description | | ----------- | ------------------------- | -------- | ------------------------------------------------------------------------ | | `target` | `YieldActionTarget` | Yes | Which yield to act on. See below. | | `amount` | `String` | Yes | Amount to deposit or withdraw. Merged into the action arguments. | | `arguments` | `YieldXyzEnterArguments?` | No | Protocol-specific inputs, such as `validatorAddress` for native staking. | There is **no `address` parameter.** The wallet address is resolved from your `Portal` instance for whichever chain the yield resolves to. If no wallet exists for that chain, the call throws `YieldXyzError.addressUnavailable`. `YieldActionTarget` is an enum with two cases, so you cannot accidentally supply both forms or neither: ```swift theme={null} public enum YieldActionTarget { case yieldId(String) case chainAndToken(chain: String, token: String) } ``` | Case | Behavior | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `.yieldId("…")` | Acts on that yield directly, skipping the defaults lookup. | | `.chainAndToken(chain:token:)` | Resolves the yield from your Portal yield defaults. `chain` must be a **full CAIP-2 id** (`"eip155:1"`); `token` must match the defaults exactly. | `.chainAndToken` requires full CAIP-2. A bare `"1"` throws `YieldXyzError.invalidChainId`. This differs from Li.Fi's `tradeAsset`, which forwards whatever chain format you give it — the two APIs shipped in the same release but do not accept the same values. `YieldSubmitOptions`: | Option | Type | Default | Description | | --------------------- | ---------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------- | | `onProgress` | `((YieldSubmitProgress) -> Void)?` | `nil` | Fired per transaction with `.signing`, `.submitted`, `.confirming`, `.confirmed`. | | `pollIntervalSeconds` | `Int` | `4` | **Seconds** between confirmation polls. Clamped to a minimum of `1`. | | `timeoutSeconds` | `Int` | `900` | **Seconds** to wait for a transaction to confirm before treating it as uncertain. Clamped to at least `pollIntervalSeconds`. | These are **seconds**, not milliseconds. The Android SDK uses `pollIntervalMs` and `timeoutMs` for the same concepts, so a value copied across platforms will be wrong by a factor of 1000. There is no per-call signer or confirmation override on iOS — signing always goes through the Portal MPC signer. ### Return value `YieldDepositResult`: | Field | Type | Description | | ------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `hashes` | `[String]` | Submitted transaction hashes, in order. Contains every hash that was submitted, regardless of final outcome. | | `yieldId` | `String` | The resolved yield id. | | `status` | `YieldSubmitResultStatus` | `.success`, `.partialSuccess`, or `.failed`. See below. | | `chain` | `String?` | Set only when you targeted via `.chainAndToken`. | | `token` | `String?` | Set only when you targeted via `.chainAndToken`. | | `yieldOpportunityDetails` | `YieldOpportunityDetails` | Action metadata — `yieldId`, `intent`, `type`, `executionPattern`, `status`, `amount`, `amountUsd`. This is action-level detail, not the full opportunity; use `discover` for that. | ### Handling Results | Status | Meaning | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `.success` | Every transaction confirmed, or the action required no confirmations. | | `.partialSuccess` | Execution stopped before all transactions confirmed — typically a confirmation timeout. Earlier transactions may have landed. | | `.failed` | A transaction was confirmed as failed on-chain. Execution stopped immediately at that point. | ```swift theme={null} let result = try await portal.yield.yieldxyz.deposit(params: params) switch result.status { case .success: print("All transactions confirmed: \(result.hashes)") case .partialSuccess: print("Stopped before completing: \(result.hashes) — check the yield balance") case .failed: print("Transaction failed on-chain: \(result.hashes.last ?? "none")") } ``` A non-empty `hashes` array does **not** mean success. Hashes are recorded as transactions are submitted, before their outcome is known. Always branch on `status`. ### Example (deposit with progress) ```swift theme={null} import PortalSwift do { let result = try await portal.yield.yieldxyz.deposit( params: YieldDepositParams( target: .yieldId("ethereum-sepolia-link-aave-v3-lending"), amount: "0.001" ), options: YieldSubmitOptions( onProgress: { progress in print("[deposit] \(progress.step.rawValue) \(progress.index + 1)/\(progress.total) \(progress.hash ?? "")") }, pollIntervalSeconds: 4, timeoutSeconds: 300 ) ) print("Status: \(result.status.rawValue)") print("Hashes: \(result.hashes)") } catch let error as YieldXyzError { print("Deposit failed: \(error.errorDescription ?? "unknown")") } ``` ### Example (targeting by chain and token) ```swift theme={null} let result = try await portal.yield.yieldxyz.deposit( params: YieldDepositParams( target: .chainAndToken(chain: "eip155:11155111", token: "ETH"), amount: "0.0000001" ) ) print("Resolved yield: \(result.yieldId)") print("Chain: \(result.chain ?? "") Token: \(result.token ?? "")") ``` `chain` and `token` on the result are populated only when you target this way. ### Example (withdraw) `withdraw` takes identical parameter shapes: ```swift theme={null} let result = try await portal.yield.yieldxyz.withdraw( params: YieldWithdrawParams( target: .yieldId("ethereum-sepolia-link-aave-v3-lending"), amount: "0.001" ) ) ``` ### Errors `deposit`, `withdraw`, and `getValidators` throw `YieldXyzError`: | Case | When | | --------------------------------- | ---------------------------------------------------------------------------------- | | `portalNotInitialized` | No Portal instance available for signing. Cannot occur on `portal.yield.yieldxyz`. | | `emptyYieldId` | An empty `yieldId` was supplied. | | `invalidChainId(String)` | `chain` was not a full CAIP-2 id. | | `noYieldForChainToken(String)` | No default yield matches that chain and token pair. | | `yieldNotFound(String)` | The yield id does not exist. | | `addressUnavailable(String)` | No wallet address for the resolved chain. Create or load a wallet first. | | `noTransactions` | The action response contained no transactions. | | `missingTransactionField(String)` | A transaction was missing a required field. | | `unsupportedNetwork(String)` | The action resolved to a network the high-level flow cannot sign for. | | `invalidSignResponse` | Signing returned an unusable response. | | `transactionFailed(String)` | A transaction failed on-chain. Carries the hash. | | `noValidators(String)` | `getValidators` found none for that yield. | | `apiError(String)` | The backend returned an error payload. Carries the message. | All cases conform to `LocalizedError`, so `errorDescription` gives a readable message. ## Get Validators Fetches the validator addresses for a native-staking yield. These are used for approval flows and for populating `arguments.validatorAddress`. ```swift theme={null} func getValidators(yieldId: String) async throws -> [YieldXyzValidator] ``` Available on both the namespace and the provider — `portal.yield.getValidators(yieldId:)` is a passthrough to `portal.yield.yieldxyz.getValidators(yieldId:)`. ```swift theme={null} let validators = try await portal.yield.getValidators(yieldId: "monad-testnet-mon-native-staking") for validator in validators { print(validator.address, validator.name ?? "unnamed") } ``` Only `address` is non-optional on `YieldXyzValidator`. Everything else — `name`, `logoURI`, `website`, `rewardRate`, `provider`, `commission`, `tvlUsd`, `votingPower`, `preferred`, `minimumStake`, `status`, and the rest — depends on the protocol and may be absent. Throws `YieldXyzError.noValidators` when the response contains no validators, and `YieldXyzError.apiError` when the backend returns an error payload. ### Low-level additions Two API methods back the high-level flow and are available directly on `portal.api.yieldxyz`: ```swift theme={null} func getYieldDefaults(includeOpportunities: Bool?) async throws -> YieldXyzGetDefaultsResponse func getYieldValidators(yieldId: String) async throws -> YieldXyzGetValidatorsResponse ``` `getYieldDefaults` returns `data` as a dictionary keyed **`"{caip2}:{TOKEN}"`** — for example `"eip155:1:USDC"`. That key is exactly what `.chainAndToken` resolves against, so this is how you discover valid chain and token pairs. Pass `includeOpportunities: true` to populate each entry's `opportunity` field. Unlike the other Yield.xyz responses, the defaults payload is **not** wrapped in `rawResponse` — read it from `data` directly. `YieldXyzEnterArguments` also gained a builder helper for replacing just the amount: ```swift theme={null} let updated = existingArguments.withAmount("0.5") ``` ## Managing Yield Positions If your Portal client has entered into a yield balance, they may have a yield balance that has an available `pendingActions`. You can use the `manage` method to perform actions on existing yield positions. For example, if the balance has a `pendingAction` of `WITHDRAW` or `CLAIM_REWARDS`, you can use the `manage` method to withdraw or claim rewards from the yield balance. For complete API documentation, see the [Yield.xyz manage yield reference](https://docs.yield.xyz/reference/actionscontroller_manageyield). ```swift theme={null} do { let userAddress = try await portal.getAddress() let manageRequest = YieldXyzManageYieldRequest( yieldId: "ethereum-sepolia-link-aave-v3-lending", address: userAddress, action: YieldActionType.WITHDRAW, // Replace with the balance's `pendingAction` item's `type` value. passthrough: "eyJhZGRyZXNzZXMiOnsiYWRkcmVzcyI6ImNvc21vczF5ZXk..." // Replace with the balance's `pendingAction` item's `passthrough` value. ) let manageResponse = try await portal.yield.yieldxyz.manage(request: manageRequest) if let rawResponse = manageResponse.data?.rawResponse { let transactions = rawResponse.transactions // Process transactions, this is described in the "Transaction Processing" section below try await processTransactions(transactions) } } catch { // Handle error print("Error managing yield position: \(error)") } ``` ## Getting Historical Actions Retrieve the history of yield actions for an address. For complete API documentation, see the [Yield.xyz get actions reference](https://docs.yield.xyz/reference/actionscontroller_getactions). ```swift theme={null} do { let userAddress = try await portal.getAddress() let request = YieldXyzGetHistoricalActionsRequest(address: userAddress) let response = try await portal.yield.yieldxyz.getHistoricalActions(request: request) if let rawResponse = response.data?.rawResponse { let pastActions = rawResponse.items // Process and display past yield actions } } catch { // Handle error print("Error getting historical actions: \(error)") } ``` ## Transaction Processing (low-level enter / exit / manage) If you use [`deposit`](#high-level-methods) or [`withdraw`](#high-level-methods), skip this section — the SDK already sequences transactions, waits for confirmation between steps, and reports hashes to Yield.xyz. This section applies to manual flows built on `enter`, `exit`, or `manage`. When `deposit` and `withdraw` sign an EVM transaction they rebuild it from the yield action's `unsignedTransaction`, copying `to`, `from`, `value`, `data` and the fee fields (`gasLimit`/`gas`, `maxFeePerGas`, `maxPriorityFeePerGas`, `gasPrice`) but **deliberately omitting `nonce`**, so the MPC signer fetches the pending nonce at signing time. If you sign manually, drop the planning `nonce` the same way — reusing it across a multi-transaction action causes nonce collisions. Yield operations can require multiple transactions. Process them sequentially, submit each, track it, and wait for on-chain confirmation (e.g. using `eth_getTransactionReceipt`) before proceeding to the next. For complete API documentation, see the [Yield.xyz submit transaction hash reference](https://docs.yield.xyz/reference/transactionscontroller_submittransactionhash) and [get transaction details reference](https://docs.yield.xyz/reference/transactionscontroller_gettransaction). For account abstraction enabled Portal clients, use `eth_getUserOperationReceipt` instead of `eth_getTransactionReceipt` to wait for confirmation, since signing returns a *user operation hash*, not a transaction hash. If you don't specify a `yieldId` on your balance queries, you'll need to call `track` after each transaction so Yield.xyz can attribute the position. Pass the **transaction hash** (extracted from `response.result.receipt.transactionHash` for AA clients), not the user operation hash. ```swift theme={null} func processTransactions(_ transactions: [YieldActionTransaction]) async throws { let sorted = transactions.sorted { $0.stepIndex < $1.stepIndex } for tx in sorted { if tx.unsignedTransaction != nil && tx.status == YieldActionTransactionStatus.CREATED { let success = await signAndSubmitAndConfirm(transaction: tx) if !success { break } } } } func signAndSubmitAndConfirm(transaction: YieldActionTransaction) async -> Bool { guard let unsignedTxJson = transaction.unsignedTransaction as? String else { return false } // Parse the unsigned transaction JSON string guard let jsonData = unsignedTxJson.data(using: .utf8), let txParams = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { return false } // Create ETHTransactionParam from the parsed JSON let ethTransaction = ETHTransactionParam( from: txParams["from"] as? String ?? "", to: txParams["to"] as? String ?? "", value: txParams["value"] as? String ?? "0x0", data: txParams["data"] as? String ?? "0x" // Portal handles gas estimation automatically ) do { // Sign and send the transaction let sendResponse = try await portal.request( chainId: transaction.network, method: .eth_sendTransaction, params: [ethTransaction], options: RequestOptions(signatureApprovalMemo: "Yield transaction") ) guard let txHash = sendResponse.result as? String else { return false } // Track the transaction with the yield system _ = try await portal.yield.yieldxyz.track( transactionId: transaction.id, txHash: txHash ) // Wait for transaction confirmation return await waitForReceipt(txHash: txHash, chainId: transaction.network) } catch { print("Error signing and submitting transaction: \(error)") return false } } func waitForReceipt( txHash: String, chainId: String, maxAttempts: Int = 30, delaySeconds: UInt64 = 2 ) async -> Bool { for _ in 0.. Prefer an explicit `case .unknown` over `default:`. With `default:`, the next case Portal adds falls into it silently; with an explicit `.unknown`, you get a compiler error and a chance to handle the new value deliberately. ### Affected enums Thirteen public enums gained `.unknown` in 7.3.0, grouped here by the type that declares them so you can find the one your compiler flagged: **From `YieldXyzGetYieldsResponse`:** `YieldXyzRateType`, `YieldXyzSource`, `YieldXyzRewardSchedule`, `YieldXyzRewardClaiming`, `YieldXyzArgumentFieldName`, `YieldXyzArgumentFieldType` **From `YieldXyzEnterYieldResponse`:** `YieldXyzActionIntent`, `YieldXyzActionType`, `YieldXyzActionExecutionPattern`, `YieldXyzActionStatus`, `YieldXyzActionTransactionStatus`, `YieldXyzActionTransactionType` **From `YieldXyzGetYieldsRequest`:** `YieldXyzMechanicsType` All thirteen conform to a new public protocol, `YieldXyzUnknownTolerantEnum`, which supplies the tolerant `Codable` decoding and an `unknownValue` fallback. You never implement or reference it directly — it exists so these enums share one decoding path, and it may show up in autocomplete or a stack trace. The reach extends past `discover`. `YieldXyzActionIntent`, `YieldXyzActionType`, `YieldXyzActionExecutionPattern`, and `YieldXyzActionStatus` are all fields on `YieldOpportunityDetails`, which is returned by the high-level [`deposit` and `withdraw`](#high-level-methods) methods. Code reading `result.yieldOpportunityDetails.status` is affected — note that these four fields are optional, so an exhaustive `switch` needs to handle `nil` as well as `.unknown`: ```swift theme={null} switch result.yieldOpportunityDetails.status { case .SUCCESS: markComplete() case .PROCESSING, .WAITING_FOR_NEXT, .CREATED: keepPolling() case .FAILED, .CANCELED, .STALE: markFailed() case .unknown: showRawStatus() case nil: showPending() } ``` ### Handling `.unknown` in your code 1. Treat `.unknown` as "display it, don't act on it". Render a neutral label rather than hiding the row, so users still see their position. 2. Don't branch business logic on `.unknown`. If a decision depends on knowing the exact source or status, read the raw response value rather than inferring from the enum. 3. Log occurrences along with the `yieldId`. A rising `.unknown` rate is a signal to upgrade the SDK. ### New `lending` source `YieldXyzSource` gained a `lending` case in 7.3.0. You encounter this enum on each entry of a discovered opportunity's `rewardRate.components` array, as `component.yieldSource`. The full case list as of 7.3.0: ```swift theme={null} public enum YieldXyzSource: String, YieldXyzUnknownTolerantEnum { case staking case restaking case protocol_incentive case points case lending case lending_interest case mev case real_world_asset_yield case validator_commission case unknown } ``` The raw string values match the case names exactly — `.protocol_incentive` encodes as `"protocol_incentive"` — so no case has a separate raw value to map. `lending` and `lending_interest` are separate cases and Yield.xyz uses both. Treat them as distinct values rather than assuming one supersedes the other. ## Best Practices 1. **Always check yield availability** before attempting to enter positions 2. **Process transactions sequentially** as yield operations often require multiple steps and are dependent on previous transactions being mined successfully 3. **Handle network errors gracefully** and provide user feedback 4. **Monitor transaction status** and provide progress updates to users 5. **Validate user balances** before initiating yield operations ## Supported Networks The yield functionality supports various networks including: * Monad (`eip155:143`) * Monad Testnet (`eip155:10143`) * Arbitrum (`eip155:42161`) * Avalanche C (`eip155:43114`) * Base (`eip155:8453`) * Base Sepolia (`eip155:84532`) * Celo (`eip155:42220`) * Core (`eip155:1116`) * Ethereum (`eip155:1`) * Ethereum Sepolia (`eip155:11155111`) * Fantom (`eip155:250`) * Gnosis (`eip155:100`) * Harmony (`eip155:1666600000`) * Hyperevm (`eip155:999`) * Katana (`eip155:747474`) * Linea (`eip155:59144`) * Moonriver (`eip155:1285`) * Optimism (`eip155:10`) * Optimism Sepolia (`eip155:11155420`) * Plasma (`eip155:9745`) * Polygon (`eip155:137`) * Polygon Amoy (`eip155:80002`) * Sonic (`eip155:146`) * Unichain (`eip155:130`) * Viction (`eip155:88`) * zkSync (`eip155:324`) * Solana (`solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`) * Solana Devnet (`solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`) * Stellar (`stellar:pubnet`) * Stellar Testnet (`stellar:testnet`) * Tron (`tron:mainnet`) ## Next Steps * Learn about [managing wallet lifecycle states](./manage-wallet-lifecycle-states) * Explore [transaction simulation](./evaluate-a-transaction) * Check out [Portal API methods](./portal-api-methods) # Swap Tokens with 0x Source: https://docs.portalhq.io/sdks/ios/guide/zerox Learn how to swap tokens using Portal's iOS SDK with 0x integration. Portal's iOS SDK provides token swapping functionality through the `portal.trading.zeroX` API. This integration allows you to retrieve swap quotes, inspect available liquidity sources, and execute token swaps using 0x. ## Overview Using the 0x integration, you can: * Fetch **indicative prices** for token swaps * Fetch **swap quotes** between supported tokens * Retrieve **available liquidity sources** * **Execute swaps** by signing and submitting transactions All swap execution is performed by submitting the transaction data returned by 0x using `portal.request`. ## Prerequisites Before using the 0x API, make sure you have: * A properly initialized Portal client * An active wallet with sufficient balance on the source network (see [Create a wallet](./create-a-wallet)) * 0x integration enabled in your Portal Dashboard (see [0x Integration](../../../integrations/Trading/zerox)) OR have a 0x API Key available *** ## Using a Custom 0x API Key (Optional) By default, Portal uses the 0x API Key that can be added through the Portal Dashboard to communicate with the 0x integration. If you have a 0x API key that you want to test with locally, you can optionally include it in the request body via the options parameter. ```swift theme={null} let sources = try await portal.trading.zeroX.getSources( chainId: "eip155:1" ) ``` If you've configured your 0x API key in the Portal Dashboard, you can ommit passing the `zeroXApiKey` parameter: ```swift theme={null} let sources = try await portal.trading.zeroX.getSources( chainId: "eip155:1" ) ``` *** ## Getting a Price (Indicative) Use `portal.trading.zeroX.getPrice` to retrieve an **indicative price** for a token swap without generating executable transaction data. This method is useful for displaying prices, estimating swap outcomes, or building preview experiences without committing to a quote. ```swift theme={null} let priceRequest = ZeroXPriceRequest( chainId: "eip155:1", buyToken: "USDC", sellToken: "ETH", sellAmount: "100000000000000" // 0.0001 ETH ) let price = try await portal.trading.zeroX.getPrice( request: priceRequest ) print("Price: \(price.data.price.price ?? "N/A")") print("Buy Amount: \(price.data.price.buyAmount)") print("Liquidity Available: \(price.data.price.liquidityAvailable ?? false)") ``` *** ## Getting a Swap Quote Use `portal.trading.zeroX.getQuote` to fetch a swap quote from 0x with executable transaction data. ```swift theme={null} let quoteRequest = ZeroXQuoteRequest( chainId: "eip155:1", buyToken: "USDC", sellToken: "ETH", sellAmount: "100000000000000" // 0.0001 ETH ) let quote = try await portal.trading.zeroX.getQuote( request: quoteRequest ) print("Quote: \(quote.data.quote.price ?? "N/A")") print("Transaction: \(quote.data.quote.transaction)") ``` *** ## Getting Liquidity Sources You can query available liquidity sources supported by 0x using `portal.trading.zeroX.getSources`. ```swift theme={null} let sources = try await portal.trading.zeroX.getSources( chainId: "eip155:1" ) print("Available sources: \(sources.data.sources)") ``` *** ## Checking Quote Issues When getting a quote, you may encounter issues related to allowances, balances, or simulation. Always check for these before executing a swap: ```swift theme={null} let quote = try await portal.trading.zeroX.getQuote( request: quoteRequest ) if let issues = quote.data.quote.issues { if let allowance = issues.allowance { print("Allowance issue: actual=\(allowance.actual), spender=\(allowance.spender)") } if let balance = issues.balance { print("Balance issue: actual=\(balance.actual), expected=\(balance.expected)") } if let simulationIncomplete = issues.simulationIncomplete, simulationIncomplete { print("Warning: Simulation incomplete") } if let invalidSources = issues.invalidSourcesPassed, !invalidSources.isEmpty { print("Invalid sources: \(invalidSources)") } } ``` *** ## Executing the Swap Once you receive a quote containing transaction data, execute the swap by sending the transaction through `portal.request`. The transaction data returned by 0x may include gas parameters such as `gas` or `gasPrice`. These fields are optional — you can omit them and let Portal estimate gas automatically, or include them if you prefer to use 0x's suggested values. ```swift theme={null} // Get the quote let quoteRequest = ZeroXQuoteRequest( chainId: "eip155:1", buyToken: "USDC", sellToken: "ETH", sellAmount: "100000000000000" // 0.0001 ETH ) let quote = try await portal.trading.zeroX.getQuote( request: quoteRequest ) // Extract transaction details let transaction = quote.data.quote.transaction let transactionDict: [String: Any] = [ "data": transaction.data, "from": transaction.from, "gas": transaction.gas, "gasPrice": transaction.gasPrice, "to": transaction.to, "value": transaction.value ] // Submit the transaction let result = try await portal.request( chainId: "eip155:1", method: .eth_sendTransaction, params: [transactionDict], options: RequestOptions(signatureApprovalMemo: "Swap via 0x") ) print("Transaction submitted: \(result)") ``` *** ## Error Handling Handle errors gracefully when interacting with the 0x API: ```swift theme={null} do { let sources = try await portal.trading.zeroX.getSources( chainId: "eip155:1" ) print("Success: \(sources)") } catch { print("Error: \(error)") // Handle specific error types if let urlError = error as? URLError { print("URL Error: \(urlError.localizedDescription)") } else if error is DecodingError { print("Decoding Error: Invalid response format") } } ``` *** ## Complete Swap Flow Example Here's a complete example of executing a token swap: ```swift theme={null} let chainId = "eip155:1" // Step 1: Get sources let sources = try await portal.trading.zeroX.getSources( chainId: chainId ) print("Sources: \(sources.data.sources)") // Step 2: Get quote let quoteRequest = ZeroXQuoteRequest( chainId: chainId, buyToken: "USDC", sellToken: "ETH", sellAmount: "100000000000000" // 0.0001 ETH ) let quote = try await portal.trading.zeroX.getQuote( request: quoteRequest ) // Step 3: Submit transaction let transaction = quote.data.quote.transaction let transactionDict: [String: Any] = [ "data": transaction.data, "from": transaction.from, "gas": transaction.gas, "gasPrice": transaction.gasPrice, "to": transaction.to, "value": transaction.value ] let result = try await portal.request( chainId: chainId, method: .eth_sendTransaction, params: [transactionDict], options: RequestOptions(signatureApprovalMemo: "Swap via 0x") ) print("Transaction submitted: \(result)") ``` *** ## Supported Networks The `portal.trading.zeroX` API supports a predefined set of EIP-155 networks. Requests using unsupported chains will fail. | Network | EIP-155 Chain ID | | ---------- | ---------------- | | Ethereum | `eip155:1` | | Optimism | `eip155:10` | | BSC | `eip155:56` | | Unichain | `eip155:130` | | Polygon | `eip155:137` | | Worldchain | `eip155:480` | | Mantle | `eip155:5000` | | Base | `eip155:8453` | | Monad | `eip155:143` | | Mode | `eip155:34443` | | Arbitrum | `eip155:42161` | | Avalanche | `eip155:43114` | | Ink | `eip155:57073` | | Linea | `eip155:59144` | | Berachain | `eip155:80094` | | Blast | `eip155:81457` | | Scroll | `eip155:534352` | 0x supports mainnet networks only — calls against testnets surface as a `ZEROX_GET_QUOTE_ERROR` from the native SDK. *** ## Next Steps * Learn how to [sign Ethereum transactions](./sign-a-transaction) * Explore how to [send tokens](./send-tokens) * Check out [Portal API methods](./portal-api-methods) # availableRecoveryMethods Source: https://docs.portalhq.io/sdks/ios/reference/availablerecoverymethods Retrieves the list of available backup methods that can be used for wallet recovery. **Function Signature** ```swift theme={null} public func availableRecoveryMethods( _ forChainId: String? = nil ) async throws -> [BackupMethods] ``` **Parameters** * `forChainId`: Optional chain identifier (e.g., "eip155:1" for Ethereum mainnet, "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" for Solana mainnet). If nil, returns backup methods from all wallets. **Returns** An array of `BackupMethods` representing completed backups, which may include: * `.GoogleDrive`: Google Drive backup * `.iCloud`: iCloud backup * `.Password`: Password-protected backup * `.Passkey`: Passkey backup * `.local`: Local backup **Throws** * `PortalClassError.clientNotAvailable` if the client is not initialized * When a specific chainId is provided: * `PortalClassError.unsupportedChainId` if the chain's namespace is not supported * `PortalClassError.noWalletFoundForChain` if no wallet exists for the specified chain **Example Usage** ```swift theme={null} // Check available recovery methods for all chains do { let methods = try await portal.availableRecoveryMethods() print("Available recovery methods: \(methods)") // Check if specific backup method is available if methods.contains(.Password) { print("Password recovery is available") } if methods.contains(.GoogleDrive) { print("Google Drive recovery is available") } } catch { print("Error checking recovery methods: \(error)") } // Check recovery methods for specific chain do { // Check Ethereum mainnet let ethMethods = try await portal.availableRecoveryMethods("eip155:1") print("Available recovery methods for Ethereum: \(ethMethods)") // Check Solana mainnet let solMethods = try await portal.availableRecoveryMethods("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp") print("Available recovery methods for Solana: \(solMethods)") } catch { print("Error checking chain-specific recovery methods: \(error)") } // Complete example with recovery do { let methods = try await portal.availableRecoveryMethods() for method in methods { switch method { case .Password: // Attempt password recovery try portal.setPassword("userPassword") let recovered = try await portal.recoverWallet(.Password) print("Recovered with password: \(recovered.ethereum)") case .GoogleDrive: // Attempt Google Drive recovery let recovered = try await portal.recoverWallet(.GoogleDrive) print("Recovered with Google Drive: \(recovered.ethereum)") case .iCloud: // Attempt iCloud recovery let recovered = try await portal.recoverWallet(.iCloud) print("Recovered with iCloud: \(recovered.ethereum)") default: print("Other recovery method available: \(method)") } } } catch { print("Error in recovery process: \(error)") } ``` **Implementation Notes** * The method only returns backup methods where the corresponding backup share pairs have a status of `.completed` * Methods with incomplete or pending backups are excluded * For chain-specific queries, the method validates the chain ID format and namespace support * The function is useful for determining which recovery options to present to users **Related Documentation** For more information about recovery methods and chain support, see: * [Recover a wallet](../guide/recover-a-wallet) # backupWallet Source: https://docs.portalhq.io/sdks/ios/reference/backupwallet Creates a backup of the wallet using the specified backup method. **Function Signature** ```swift theme={null} public func backupWallet( _ method: BackupMethods, usingProgressCallback: ((MpcStatus) -> Void)? = nil ) async throws -> (cipherText: String, storageCallback: () async throws -> Void) ``` **Parameters** * `method`: The backup method to use. Supported methods: * `.GoogleDrive`: Back up to Google Drive * `.iCloud`: Back up to iCloud * `.Password`: Back up with password protection * `.Passkey`: Back up with passkey authentication * `.Firebase`: Back up with Firebase Auth Backup * `.local`: Back up to local storage * `usingProgressCallback`: Optional callback to track backup progress. Receives an `MpcStatus` object containing: * `status`: Current operation being performed: * `.readingShare`: Reading the stored share * `.generatingShare`: Generate the share * `.parsingShare`: Parsing the share data * `.encryptingShare`: Encrypt the share data * `.storingShare`: Saving the recovered share * `.done`: Process completed * `done`: Boolean indicating whether the operation is complete **Returns** A tuple containing: * `cipherText`: The encrypted backup data * `storageCallback`: A callback function that must be called to complete the backup process **Throws** Various backup method-specific errors if the backup process fails **Example Usage** ```swift theme={null} do { let backup = try await portal.backupWallet(.iCloud) { status in switch status.status { case .readingShare: print("Reading share...") case .generatingShare: print("Creating backup share...") case .encryptingShare: print("Encrypting data...") case .storingShare: print("Storing backup...") case .done: print("Backup completed!") default: break } } // Complete the backup process try await backup.storageCallback() print("Backup cipher text: \(backup.cipherText)") } catch { print("Error backing up wallet: \(error)") } ``` # buildEip155Transaction Source: https://docs.portalhq.io/sdks/ios/reference/buildeip155transaction Builds an EIP-155 compliant transaction for Ethereum-compatible chains. This method constructs a transaction object that includes replay protection as specified in EIP-155. **Function Signature** ```swift theme={null} swiftCopypublic func buildEip155Transaction( chainId: String, params: BuildTransactionParam ) async throws -> BuildEip115TransactionResponse ``` **Parameters** 1. `chainId`: The chain identifier in CAIP-2 format (e.g., "eip155:1" for Ethereum mainnet) 2. `params`: A `BuildTransactionParam` object containing: * `to`: Recipient's address * `token`: Token identifier or contract address * `amount`: Amount to transfer as a string **Returns** A `BuildEip115TransactionResponse` containing: * `transaction`: An `Eip115Transaction` object with: * `from`: Sender's address * `to`: Recipient's address * `data`: Optional transaction data for contract interactions * `value`: Optional transaction value in wei * `metadata`: A `BuildTransactionMetaData` object containing: * `amount`: Formatted transaction amount * `fromAddress`: Sender's address * `toAddress`: Recipient's address * `tokenAddress`: Optional token contract address for ERC20 transfers * `tokenDecimals`: Number of decimal places for the token * `tokenSymbol`: Optional token symbol * `rawAmount`: Raw transaction amount value * `error`: Optional error message if the build process encounters issues **Example Usage** ```swift theme={null} // Native ETH transfer do { let params = BuildTransactionParam( to: "0x1234567890123456789012345678901234567890", token: "ETH", amount: "1000000000000000000" // 1 ETH ) let response = try await portal.buildEip155Transaction( chainId: "eip155:1", params: params ) print("Transaction details:") print("From: \(response.transaction.from)") print("To: \(response.transaction.to)") print("Value: \(response.transaction.value ?? "0")") print("\nMetadata:") print("Amount: \(response.metadata.amount)") print("Token Symbol: \(response.metadata.tokenSymbol ?? "ETH")") if let error = response.error { print("Warning: \(error)") } } catch { print("Failed to build transaction: \(error)") } // ERC20 token transfer do { // Example: Transfer 100 USDC let params = BuildTransactionParam( to: "0x1234567890123456789012345678901234567890", token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC contract address amount: "100000000" // 100 USDC (6 decimals) ) let response = try await portal.buildEip155Transaction( chainId: "eip155:1", params: params ) // Verify the built transaction print("Token Transfer Details:") print("Contract Address: \(response.metadata.tokenAddress ?? "N/A")") print("Amount: \(response.metadata.amount) \(response.metadata.tokenSymbol ?? "tokens")") print("From: \(response.metadata.fromAddress)") print("To: \(response.metadata.toAddress)") print("Raw Amount: \(response.metadata.rawAmount)") } catch { print("Failed to build token transfer: \(error)") } // Example with transaction validation func buildAndValidateTransaction( to: String, token: String, amount: String ) async throws { // Build the transaction let params = BuildTransactionParam( to: to, token: token, amount: amount ) let response = try await portal.buildEip155Transaction( chainId: "eip155:1", params: params ) // Validate the response guard response.error == nil else { throw TransactionError.buildFailed(response.error!) } // Validate addresses guard response.metadata.fromAddress.starts(with: "0x"), response.metadata.toAddress.starts(with: "0x") else { throw TransactionError.invalidAddress } // Validate amount guard let rawAmount = Double(response.metadata.rawAmount), rawAmount > 0 else { throw TransactionError.invalidAmount } print("Transaction validated successfully") print("Ready to send: \(response.metadata.amount) \(response.metadata.tokenSymbol ?? "tokens")") } ``` **Implementation Notes** 1. Transaction Types: * For native ETH transfers, use "ETH" as the token identifier * For ERC20 transfers, use the token's contract address as the token identifier * Amount should be provided in the token's smallest unit (wei for ETH, token-specific decimal places for ERC20) 2. Token Handling: * Native ETH is identified by "ETH" token value * ERC20 tokens are identified by their contract address * Amount decimal places must match the token's decimal configuration 3. Amount Formats: * ETH amounts should be in wei (1 ETH = 1e18 wei) * Token amounts should account for the token's decimal places * All amounts should be provided as strings to preserve precision 4. Error Handling: * Check response.error for build-specific issues * Validate addresses are in correct Ethereum format * Verify amount format matches token decimals * Handle network-specific errors during transaction building **Related Documentation** For more information about transaction building, see: * [Submitting an EVM Transaction](../guide/sign-a-transaction) # buildSolanaTransaction Source: https://docs.portalhq.io/sdks/ios/reference/buildsolanatransaction Builds a Solana transaction for token transfers, handling both native SOL and SPL tokens with required program instructions. **Function Signature** ```swift theme={null} public func buildSolanaTransaction( chainId: String, params: BuildTransactionParam ) async throws -> BuildSolanaTransactionResponse ``` **Parameters** * `chainId`: The chain identifier in CAIP-2 format (e.g., "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" for Solana mainnet) * `params`: A `BuildTransactionParam` object containing: * `to`: Recipient's Solana address * `token`: Token identifier ("SOL" for native transfers, mint address for SPL tokens) * `amount`: Amount to transfer as a string (in lamports for SOL, raw amount for SPL tokens) **Returns** A `BuildSolanaTransactionResponse` containing: * `transaction`: The serialized transaction string ready for signing * `metadata`: A `BuildTransactionMetaData` object containing: * `amount`: Formatted transaction amount * `fromAddress`: Sender's address * `toAddress`: Recipient's address * `tokenAddress`: Optional token mint address for SPL tokens * `tokenDecimals`: Number of decimal places for the token * `tokenSymbol`: Optional token symbol * `rawAmount`: Raw transaction amount value * `error`: Optional error message if the build process encounters issues **Example Usage** 1. Native SOL Transfer: ```swift theme={null} do { let params = BuildTransactionParam( to: "GxvUWHWwMpep8e6tXvVdaRmwEzKcUjhLYLhpXhS6wQtY", token: "SOL", amount: "1000000000" // 1 SOL = 1e9 lamports ) let response = try await portal.buildSolanaTransaction( chainId: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", params: params ) print("Transaction details:") print("Amount: \(response.metadata.amount) SOL") print("From: \(response.metadata.fromAddress)") print("To: \(response.metadata.toAddress)") print("Raw amount: \(response.metadata.rawAmount)") // Store transaction for signing let serializedTx = response.transaction } catch { print("Failed to build SOL transfer: \(error)") } ``` 2. SPL Token Transfer: ```swift theme={null} do { let params = BuildTransactionParam( to: "GxvUWHWwMpep8e6tXvVdaRmwEzKcUjhLYLhpXhS6wQtY", token: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC mint address amount: "100000000" // 100 USDC (6 decimals) ) let response = try await portal.buildSolanaTransaction( chainId: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", params: params ) print("SPL Token Transfer Details:") print("Token: \(response.metadata.tokenSymbol ?? "Unknown")") print("Token Address: \(response.metadata.tokenAddress ?? "N/A")") print("Amount: \(response.metadata.amount)") print("Decimals: \(response.metadata.tokenDecimals)") } catch { print("Failed to build token transfer: \(error)") } ``` **Implementation Notes** 1. Amount Formatting * For native SOL: ```swift theme={null} // SOL has 9 decimal places "1000000000" // 1 SOL "500000000" // 0.5 SOL ``` * For SPL tokens: ```swift theme={null} // USDC has 6 decimal places "1000000" // 1 USDC "500000" // 0.5 USDC ``` 2. Token Identifiers ```swift theme={null} // Native SOL token: "SOL" // Common SPL Tokens token: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" // USDC token: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" // USDT ``` 3. Address Validation * Solana addresses must be Base58 encoded * Must be between 32 and 44 characters in length * Example validation: ```swift theme={null} func isValidSolanaAddress(_ address: String) -> Bool { return address.count >= 32 && address.count <= 44 } ``` 4. Error Handling ```swift theme={null} if let error = response.error { // Build-specific error throw TransactionError.buildFailed(error) } // Validate address format guard isValidSolanaAddress(response.metadata.toAddress) else { throw TransactionError.invalidAddress } ``` **Related Documentation** For more information about Solana transaction building, see: * [Submitting a Solana Transaction](../guide/sign-a-transaction) # createWallet Source: https://docs.portalhq.io/sdks/ios/reference/createwallet Creates a new wallet and generates addresses for supported chains. **Function Signature** ```swift theme={null} public func createWallet( usingProgressCallback: ((MpcStatus) -> Void)? = nil ) async throws -> PortalCreateWalletResponse ``` **Parameters** * **`usingProgressCallback`**: An optional callback for tracking the progress of wallet creation. It receives an `MpcStatus` object with the following * `statuses`: * **`generatingShare`**: Creating the initial share * **`parsingShare`**: Processing the share data * **`storingShare`**: Saving the encrypted share * **`done`**: Process completed * `done`: Boolean indicating whether the whole operation is complete **Returns** * **`PortalCreateWalletResponse`**: Contains the following: * **`ethereum`**: Generated Ethereum address * **`solana`**: Generated Solana address **Throws** * `PortalClassError.cannotCreateWallet`: If the wallet creation process fails. **Example Usage** ```swift theme={null} do { let wallet = try await portal.createWallet { status in switch status.status { case .generatingShare: print("Generating share...") case .parsingShare: print("Processing share data...") case .storingShare: print("Saving share...") case .done: print("Wallet creation completed!") default: break } } print("Ethereum address: \(wallet.ethereum)") print("Solana address: \(wallet.solana)") } catch { print("Error creating wallet: \(error)") } ``` # approveAndSubmit Source: https://docs.portalhq.io/sdks/ios/reference/delegationsapproveandsubmit Approves a token delegation and signs and broadcasts the resulting transaction(s) in one call, returning the submitted transaction hashes. **Function Signature** ```swift theme={null} public func approveAndSubmit( request: ApproveDelegationRequest, options: DelegationSubmitOptions ) async throws -> DelegationSubmitResult // Convenience overload, using the signer Portal already installed for you public func approveAndSubmit( request: ApproveDelegationRequest ) async throws -> DelegationSubmitResult ``` **Description** Builds the approval transaction(s) for a token delegation, then signs and broadcasts each one sequentially and returns the resulting transaction hashes. This replaces calling `approve` and signing the returned transactions yourself. `approveAndSubmit` broadcasts each transaction and returns as soon as it is accepted by the network. It **does not wait for on-chain confirmation**. A hash in `DelegationSubmitResult.hashes` means the transaction was submitted, not that it succeeded — the approval can still revert. If your flow depends on the delegation being active, wait for the receipt yourself, or poll `getStatus` before proceeding. **Parameters** | Parameter | Type | Required | Description | | --------- | -------------------------- | -------- | ----------------------------------------------------------------------------------------------------------- | | `request` | `ApproveDelegationRequest` | Yes | The approval to build and submit. | | `options` | `DelegationSubmitOptions` | No | Per-call signer override and progress callback. Omit the argument entirely to use the convenience overload. | `ApproveDelegationRequest`: | Parameter | Type | Required | Description | | ----------------- | -------- | -------- | -------------------------------------------------------------------------------------------- | | `chain` | `String` | Yes | CAIP-2 chain ID, for example `eip155:11155111` or `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`. | | `token` | `String` | Yes | Token symbol or contract address, for example `USDC`. | | `delegateAddress` | `String` | Yes | The address being granted permission to spend the tokens. | | `amount` | `String` | Yes | The amount to delegate, for example `0.01`. | `DelegationSubmitOptions`: | Parameter | Type | Required | Description | | ------------------------ | --------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `signAndSendTransaction` | `DelegationSignAndSend?` | No | Per-call signer override. Takes priority over the instance signer set with `setSignAndSendTransaction(_:)`. | | `onProgress` | `((DelegationSubmitProgress) -> Void)?` | No | Called as each transaction is signed and submitted. `progress.step` is `.signing` or `.submitted`, and `progress.hash` is populated only on `.submitted`. | **Returns** A `DelegationSubmitResult` containing: | Field | Type | Description | | -------- | ---------- | ---------------------------------------------------------------------------------- | | `hashes` | `[String]` | One hash per broadcast transaction, in submission order. Submitted, not confirmed. | **Throws** | Error | When | | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `DelegationsError.noSignerConfigured` | No signer was available. This cannot happen on `portal.delegations`, which `Portal` wires for you — only when you construct `Delegations` yourself. | | `DelegationsError.noTransactions` | The approval response contained no transactions to submit. | | `DelegationsError.invalidTransactionHash(index:chainId:)` | The signer returned a value that is not a usable hash for that chain. `index` identifies which transaction in the sequence it was. | | `URLError` and decoding errors | The request to build the approval failed. | **Example Usage** ```swift theme={null} import PortalSwift Task { do { let request = ApproveDelegationRequest( chain: "eip155:11155111", token: "USDC", delegateAddress: "0xa944e86eb36f039becd1843132347eb5b8501562", amount: "0.01" ) let result = try await portal.delegations.approveAndSubmit( request: request, options: DelegationSubmitOptions( onProgress: { progress in switch progress.step { case .signing: print("Signing \(progress.index + 1)/\(progress.total)") case .submitted: print("Submitted: \(progress.hash ?? "")") } } ) ) // These are broadcast, not confirmed. print("Submitted hashes: \(result.hashes)") } catch DelegationsError.noTransactions { print("The approval response contained no transactions to submit.") } catch DelegationsError.invalidTransactionHash(let index, let chainId) { print("Signer returned an unusable hash for transaction \(index) on \(chainId)") } catch { print("Error approving and submitting delegation: \(error)") } } // Without options, using the signer Portal installed Task { do { let request = ApproveDelegationRequest( chain: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", token: "USDC", delegateAddress: "7smgSuU5mjP7QY5yWGdaTfgKn8hUWwvQgfvgcZB3HmJi", amount: "0.01" ) let result = try await portal.delegations.approveAndSubmit(request: request) print("Submitted hashes: \(result.hashes)") } catch { print("Error approving and submitting delegation: \(error)") } } ``` **Related Documentation** * [Manage Token Delegations](../guide/delegations) * [Token delegations](/resources/delegations) # revokeAndSubmit Source: https://docs.portalhq.io/sdks/ios/reference/delegationsrevokeandsubmit Revokes a token delegation and signs and broadcasts the resulting transaction(s) in one call, returning the submitted transaction hashes. **Function Signature** ```swift theme={null} public func revokeAndSubmit( request: RevokeDelegationRequest, options: DelegationSubmitOptions ) async throws -> DelegationSubmitResult // Convenience overload, using the signer Portal already installed for you public func revokeAndSubmit( request: RevokeDelegationRequest ) async throws -> DelegationSubmitResult ``` **Description** Builds the revocation transaction(s) for a token delegation, then signs and broadcasts each one sequentially and returns the resulting transaction hashes. This replaces calling `revoke` and signing the returned transactions yourself. `revokeAndSubmit` broadcasts each transaction and returns as soon as it is accepted by the network. It **does not wait for on-chain confirmation**. A hash in `DelegationSubmitResult.hashes` means the transaction was submitted, not that it succeeded — the revocation can still revert, and the delegation may still be active when this call returns. Poll `getStatus` before telling a user their delegation is gone. **Parameters** | Parameter | Type | Required | Description | | --------- | ------------------------- | -------- | ----------------------------------------------------------------------------------------------------------- | | `request` | `RevokeDelegationRequest` | Yes | The revocation to build and submit. | | `options` | `DelegationSubmitOptions` | No | Per-call signer override and progress callback. Omit the argument entirely to use the convenience overload. | `RevokeDelegationRequest`: | Parameter | Type | Required | Description | | ----------------- | -------- | -------- | -------------------------------------------------------------------------------------------- | | `chain` | `String` | Yes | CAIP-2 chain ID, for example `eip155:11155111` or `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`. | | `token` | `String` | Yes | Token symbol or contract address, for example `USDC`. | | `delegateAddress` | `String` | Yes | The address whose spending permission is being removed. | `DelegationSubmitOptions`: | Parameter | Type | Required | Description | | ------------------------ | --------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `signAndSendTransaction` | `DelegationSignAndSend?` | No | Per-call signer override. Takes priority over the instance signer set with `setSignAndSendTransaction(_:)`. | | `onProgress` | `((DelegationSubmitProgress) -> Void)?` | No | Called as each transaction is signed and submitted. `progress.step` is `.signing` or `.submitted`, and `progress.hash` is populated only on `.submitted`. | **Returns** A `DelegationSubmitResult` containing: | Field | Type | Description | | -------- | ---------- | ---------------------------------------------------------------------------------- | | `hashes` | `[String]` | One hash per broadcast transaction, in submission order. Submitted, not confirmed. | **Throws** | Error | When | | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `DelegationsError.noSignerConfigured` | No signer was available. This cannot happen on `portal.delegations`, which `Portal` wires for you — only when you construct `Delegations` yourself. | | `DelegationsError.noTransactions` | The revoke response contained no transactions to submit. | | `DelegationsError.invalidTransactionHash(index:chainId:)` | The signer returned a value that is not a usable hash for that chain. `index` identifies which transaction in the sequence it was. | | `URLError` and decoding errors | The request to build the revocation failed. | **Example Usage** ```swift theme={null} import PortalSwift Task { do { let request = RevokeDelegationRequest( chain: "eip155:11155111", token: "USDC", delegateAddress: "0xa944e86eb36f039becd1843132347eb5b8501562" ) let result = try await portal.delegations.revokeAndSubmit(request: request) // These are broadcast, not confirmed. print("Revoke submitted: \(result.hashes)") } catch DelegationsError.noTransactions { print("The revoke response contained no transactions to submit.") } catch { print("Error revoking delegation: \(error)") } } // With progress reporting Task { do { let request = RevokeDelegationRequest( chain: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", token: "USDC", delegateAddress: "7smgSuU5mjP7QY5yWGdaTfgKn8hUWwvQgfvgcZB3HmJi" ) let result = try await portal.delegations.revokeAndSubmit( request: request, options: DelegationSubmitOptions( onProgress: { progress in switch progress.step { case .signing: print("Signing \(progress.index + 1)/\(progress.total)") case .submitted: print("Submitted: \(progress.hash ?? "")") } } ) ) print("Revoke submitted: \(result.hashes)") } catch DelegationsError.invalidTransactionHash(let index, let chainId) { print("Signer returned an unusable hash for transaction \(index) on \(chainId)") } catch { print("Error revoking delegation: \(error)") } } ``` **Related Documentation** * [Manage Token Delegations](../guide/delegations) * [Token delegations](/resources/delegations) # setSignAndSendTransaction Source: https://docs.portalhq.io/sdks/ios/reference/delegationssetsignandsendtransaction Sets the instance-level signer used by the high-level delegation submit methods to sign and broadcast each delegation transaction. **Function Signature** ```swift theme={null} public func setSignAndSendTransaction( _ fn: @escaping DelegationSignAndSend ) ``` Where: ```swift theme={null} public typealias DelegationSignAndSend = ( _ transaction: DelegationTransaction, _ chainId: String ) async throws -> String ``` **Description** Replaces the signer that `approveAndSubmit`, `revokeAndSubmit`, and `transferAndSubmit` use when no per-call `DelegationSubmitOptions.signAndSendTransaction` is provided. The signer receives one transaction and the CAIP-2 chain ID, signs and broadcasts it, and returns the transaction hash. `Portal` already installs a signer on `portal.delegations` that routes `.evm` transactions to `eth_sendTransaction` and `.solana` transactions to `sol_signAndSendTransaction`, so you only need this method to change that behavior — for example to attach a `signatureApprovalMemo`, add your own logging, or route signing through a different key. Signer precedence is **per-call `DelegationSubmitOptions.signAndSendTransaction` → the signer set here → the `Portal` default**. **Parameters** | Parameter | Type | Required | Description | | --------- | ----------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `fn` | `DelegationSignAndSend` | Yes | Signs and broadcasts a single `DelegationTransaction` and returns its hash. Returning an empty hash causes the submit method to throw `DelegationsError.invalidTransactionHash`. | The `transaction` argument is a `DelegationTransaction`: ```swift theme={null} public enum DelegationTransaction: Equatable { case evm(ConstructedEipTransaction) case solana(String) // base64-encoded public var evmTransaction: ConstructedEipTransaction? { get } public var solanaTransaction: String? { get } } ``` Each accessor returns `nil` for the other case, so you can branch with `if let` instead of writing a full `switch`. `ConstructedEipTransaction` is `Codable`, so you can encode it for logging or to hand it off to another process. | Field | Type | Description | | ------- | --------- | ---------------------------------- | | `from` | `String` | The sender address. | | `to` | `String` | The contract or recipient address. | | `data` | `String?` | The calldata, when present. | | `value` | `String?` | The native value, when present. | **Returns** Nothing. The signer is stored on the instance and used by subsequent submit calls. **Throws** Nothing. Errors from the signer itself surface from whichever submit method invoked it. **Notes** * The stored signer is not access-synchronized. Set it once — during app setup, before any submit call — rather than changing it while a submit is in flight. To vary the signer per call, pass `DelegationSubmitOptions.signAndSendTransaction` instead. **Example Usage** ```swift theme={null} import PortalSwift enum MyAppError: Error { case missingTransactionHash case unsupportedTransaction } func sendEvmDelegationTransaction( _ transaction: ConstructedEipTransaction, chainId: String ) async throws -> String { var params: [String: String] = [ "from": transaction.from, "to": transaction.to ] if let data = transaction.data { params["data"] = data } if let value = transaction.value { params["value"] = value } let response = try await portal.request( chainId: chainId, method: .eth_sendTransaction, params: [params], options: RequestOptions(signatureApprovalMemo: "Delegation transaction") ) guard let hash = response.result as? String else { throw MyAppError.missingTransactionHash } return hash } func sendSolanaDelegationTransaction( _ encodedTransaction: String, chainId: String ) async throws -> String { let response = try await portal.request( chainId: chainId, method: .sol_signAndSendTransaction, params: [encodedTransaction], options: RequestOptions(signatureApprovalMemo: "Delegation transaction") ) guard let hash = response.result as? String else { throw MyAppError.missingTransactionHash } return hash } // Branch with the accessors portal.delegations.setSignAndSendTransaction { transaction, chainId in if let evmTransaction = transaction.evmTransaction { return try await sendEvmDelegationTransaction(evmTransaction, chainId: chainId) } if let encodedTransaction = transaction.solanaTransaction { return try await sendSolanaDelegationTransaction(encodedTransaction, chainId: chainId) } throw MyAppError.unsupportedTransaction } // Or match the enum, when you want the compiler to make you handle both cases portal.delegations.setSignAndSendTransaction { transaction, chainId in switch transaction { case let .evm(evmTransaction): return try await sendEvmDelegationTransaction(evmTransaction, chainId: chainId) case let .solana(encodedTransaction): return try await sendSolanaDelegationTransaction(encodedTransaction, chainId: chainId) } } // Every submit call now goes through your signer Task { do { let request = ApproveDelegationRequest( chain: "eip155:11155111", token: "USDC", delegateAddress: "0xa944e86eb36f039becd1843132347eb5b8501562", amount: "0.01" ) let result = try await portal.delegations.approveAndSubmit(request: request) print("Submitted hashes: \(result.hashes)") } catch DelegationsError.invalidTransactionHash(let index, let chainId) { print("Signer returned an unusable hash for transaction \(index) on \(chainId)") } catch { print("Error approving and submitting delegation: \(error)") } } ``` **Related Documentation** * [Manage Token Delegations](../guide/delegations) * [Token delegations](/resources/delegations) # transferAndSubmit Source: https://docs.portalhq.io/sdks/ios/reference/delegationstransferandsubmit Transfers tokens using delegated authority and signs and broadcasts the resulting transaction(s) in one call, returning the submitted transaction hashes. **Function Signature** ```swift theme={null} public func transferAndSubmit( request: TransferFromRequest, options: DelegationSubmitOptions ) async throws -> DelegationSubmitResult // Convenience overload, using the signer Portal already installed for you public func transferAndSubmit( request: TransferFromRequest ) async throws -> DelegationSubmitResult ``` **Description** Builds the transfer transaction(s) for a delegated transfer, then signs and broadcasts each one sequentially and returns the resulting transaction hashes. Your wallet must already be an approved delegate for `fromAddress`, and the amount must not exceed the approved allowance. This replaces calling `transferFrom` and signing the returned transactions yourself. `transferAndSubmit` broadcasts each transaction and returns as soon as it is accepted by the network. It **does not wait for on-chain confirmation**. A hash in `DelegationSubmitResult.hashes` means the transaction was submitted, not that it succeeded — the transfer can still revert, for example if the allowance is insufficient. Wait for the receipt yourself before treating the tokens as moved. **Parameters** | Parameter | Type | Required | Description | | --------- | ------------------------- | -------- | ----------------------------------------------------------------------------------------------------------- | | `request` | `TransferFromRequest` | Yes | The delegated transfer to build and submit. | | `options` | `DelegationSubmitOptions` | No | Per-call signer override and progress callback. Omit the argument entirely to use the convenience overload. | `TransferFromRequest`: | Parameter | Type | Required | Description | | ------------- | -------- | -------- | -------------------------------------------------------------------------------------------- | | `chain` | `String` | Yes | CAIP-2 chain ID, for example `eip155:11155111` or `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`. | | `token` | `String` | Yes | Token symbol or contract address, for example `USDC`. | | `fromAddress` | `String` | Yes | The token owner's address — the account that approved the delegation. | | `toAddress` | `String` | Yes | The recipient's address. | | `amount` | `String` | Yes | The amount to transfer, for example `0.01`. | `DelegationSubmitOptions`: | Parameter | Type | Required | Description | | ------------------------ | --------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `signAndSendTransaction` | `DelegationSignAndSend?` | No | Per-call signer override. Takes priority over the instance signer set with `setSignAndSendTransaction(_:)`. | | `onProgress` | `((DelegationSubmitProgress) -> Void)?` | No | Called as each transaction is signed and submitted. `progress.step` is `.signing` or `.submitted`, and `progress.hash` is populated only on `.submitted`. | **Returns** A `DelegationSubmitResult` containing: | Field | Type | Description | | -------- | ---------- | ---------------------------------------------------------------------------------- | | `hashes` | `[String]` | One hash per broadcast transaction, in submission order. Submitted, not confirmed. | **Throws** | Error | When | | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `DelegationsError.noSignerConfigured` | No signer was available. This cannot happen on `portal.delegations`, which `Portal` wires for you — only when you construct `Delegations` yourself. | | `DelegationsError.noTransactions` | The transfer response contained no transactions to submit. | | `DelegationsError.invalidTransactionHash(index:chainId:)` | The signer returned a value that is not a usable hash for that chain. `index` identifies which transaction in the sequence it was. | | `URLError` and decoding errors | The request to build the transfer failed. | **Example Usage** ```swift theme={null} import PortalSwift Task { do { let request = TransferFromRequest( chain: "eip155:11155111", token: "USDC", fromAddress: "0x099699ed181517d4ce0ba4487bea671d31bb1db5", // Token owner toAddress: "0xdFd8302f44727A6348F702fF7B594f127dE3A902", // Recipient amount: "0.01" ) let result = try await portal.delegations.transferAndSubmit(request: request) // These are broadcast, not confirmed. print("Transfer submitted: \(result.hashes)") } catch DelegationsError.noTransactions { print("The transfer response contained no transactions to submit.") } catch DelegationsError.invalidTransactionHash(let index, let chainId) { print("Signer returned an unusable hash for transaction \(index) on \(chainId)") } catch { print("Error transferring delegated tokens: \(error)") } } // Solana, with progress reporting Task { do { let request = TransferFromRequest( chain: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", token: "USDC", fromAddress: "ARttPLesu9RiX6H111Pfdc9Y2DhGy1B8P8jyyrD8Cj5b", // Token owner toAddress: "GPsPXxoQA51aTJJkNHtFDFYui5hN5UxcFPnheJEHa5Du", // Recipient amount: "0.01" ) let result = try await portal.delegations.transferAndSubmit( request: request, options: DelegationSubmitOptions( onProgress: { progress in switch progress.step { case .signing: print("Signing \(progress.index + 1)/\(progress.total)") case .submitted: print("Submitted: \(progress.hash ?? "")") } } ) ) print("Transfer submitted: \(result.hashes)") } catch { print("Error transferring delegated tokens: \(error)") } } ``` **Delegation roles**: `fromAddress` is the token owner who approved the delegation. Your wallet — the delegate — signs the transaction that moves tokens from the owner to `toAddress`. **Related Documentation** * [Manage Token Delegations](../guide/delegations) * [Token delegations](/resources/delegations) # doesWalletExist Source: https://docs.portalhq.io/sdks/ios/reference/doeswalletexist Checks whether a wallet exists with completed signing shares. This method verifies the presence of a wallet by checking for completed signing shares. **Function Signature** ```swift theme={null} public func doesWalletExist( _ forChainId: String? = nil ) async throws -> Bool ``` **Parameters** * `forChainId`: Optional chain identifier (e.g., "eip155:1" for Ethereum mainnet, "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" for Solana mainnet). If nil, checks for wallets across all chains. **Returns** A boolean value indicating whether a wallet exists: * When chainId is provided: * Returns `true` if a wallet exists for the specified chain and has at least one completed signing share * Returns `false` if no wallet exists or if no signing shares are completed * When chainId is nil: * Returns `true` if any wallet exists and has at least one completed signing share * Returns `false` if no wallets exist or if no signing shares are completed **Throws** * `PortalClassError.clientNotAvailable` if the client is not initialized **Example Usage** ```swift theme={null} // Check for any existing wallet do { let exists = try await portal.doesWalletExist() if exists { print("Wallet exists with completed signing shares") } else { print("No wallet found or no completed signing shares") } } catch { print("Error checking wallet existence: \(error)") } // Check for specific chain wallets do { // Check Ethereum wallet let ethExists = try await portal.doesWalletExist("eip155:1") if ethExists { print("Ethereum wallet exists") } // Check Solana wallet let solExists = try await portal.doesWalletExist("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp") if solExists { print("Solana wallet exists") } } catch { print("Error checking chain-specific wallets: \(error)") } // Complete example with wallet creation flow do { let exists = try await portal.doesWalletExist() if !exists { // Create new wallet if none exists let newWallet = try await portal.createWallet { status in print("Creation status: \(status)") } print("Created new wallet with ETH address: \(newWallet.ethereum)") // Backup the wallet let backup = try await portal.backupWallet(.iCloud) { status in print("Backup status: \(status)") } try await backup.storageCallback() } else { // Wallet exists, fetch addresses let addresses = try await portal.getAddresses() if let ethAddress = addresses[.eip155] { print("Existing ETH address: \(ethAddress ?? "Not found")") } } } catch { print("Error in wallet management flow: \(error)") } ``` **Implementation Notes** * The method checks for the presence of signing shares with a status of `.completed` * Incomplete or pending signing shares are not considered when determining wallet existence * The check can be performed for a specific blockchain or across all supported chains * Useful for determining whether to trigger wallet creation or recovery flows **Usage in App Flow** 1. Initial App Launch: ```swift theme={null} if try await portal.doesWalletExist() { // Navigate to main wallet interface } else { // Show wallet creation/recovery options } ``` 2. Chain-Specific Features: ```swift theme={null} if try await portal.doesWalletExist("eip155:1") { // Enable Ethereum-specific features } else { // Prompt for Ethereum wallet creation } ``` **Related Documentation** For more information about wallet management, see: * [Wallet Lifecycle](../guide/manage-wallet-lifecycle-states) # ejectPrivateKeys Source: https://docs.portalhq.io/sdks/ios/reference/ejectprivatekeys Extracts all private keys from the wallet using a specified backup method. **Function Signature** ```swift theme={null} public func ejectPrivateKeys( _ method: BackupMethods, withCipherText: String? = nil, andOrganizationBackupShare: String? = nil, andOrganizationSolanaBackupShare: String? = nil ) async throws -> [PortalNamespace: String] ``` **Parameters** * `method`: The backup method to use. Supported methods: * `.GoogleDrive`: Authenticate using Google Drive backup * `.iCloud`: Authenticate using iCloud backup * `.Password`: Authenticate using password * `.Passkey`: Authenticate using passkey * `.local`: Authenticate using local backup * `withCipherText`: Optional cipher text from a previous backup * `andOrganizationBackupShare`: Optional backup share provided by the organization for EVM chains * `andOrganizationSolanaBackupShare`: Optional backup share provided by the organization specifically for Solana **Returns** A dictionary mapping `PortalNamespace` to private keys, where: * `.eip155` key contains the Ethereum/EVM private key * `.solana` key contains the Solana private key **Throws** Various backup method-specific errors if authentication fails **Warning** Providing the custodian backup share to the client device puts both MPC shares on a single device, removing the multi-party security benefits of MPC. This operation should only be done for users who want to move off of MPC and into a single private key. **Use `portal.ejectPrivateKeys()` at your own risk!** **Example Usage** ```swift theme={null} do { let privateKeys = try await portal.ejectPrivateKeys( .iCloud, withCipherText: backupCipherText, andOrganizationBackupShare: orgBackupShare, andOrganizationSolanaBackupShare: orgSolanaBackupShare ) if let ethPrivateKey = privateKeys[.eip155] { print("Ethereum private key: \(ethPrivateKey)") } if let solanaPrivateKey = privateKeys[.solana] { print("Solana private key: \(solanaPrivateKey)") } } catch { print("Error ejecting private keys: \(error)") } ``` # evaluateTransaction Source: https://docs.portalhq.io/sdks/ios/reference/evaluatetransaction Evaluates a transaction for security and risk assessment, providing both validation and simulation results. **Function Signature** ```swift theme={null} public func evaluateTransaction( chainId: String, transaction: EvaluateTransactionParam, operationType: EvaluateTransactionOperationType? = nil ) async throws -> BlockaidValidateTrxRes ``` **Parameters** 1. `chainId`: The chain identifier in CAIP-2 format (e.g., "eip155:1" for Ethereum mainnet) 2. `transaction`: An `EvaluateTransactionParam` object containing: * `to`:\* Destination address * `value`: (Optional) Transaction value in wei * `data`: (Optional) Transaction data for contract interactions * `maxFeePerGas`: (Optional) Maximum total fee per gas unit * `maxPriorityFeePerGas`: (Optional) Maximum priority fee per gas unit * `gas`: (Optional) Gas limit * `gasPrice`: (Optional) Gas price for legacy transactions 3. `operationType`: Optional `EvaluateTransactionOperationType` with values: * `.validation`: Perform security validation only * `.simulation`: Perform transaction simulation only * `.all`: Perform both validation and simulation **Returns** A `BlockaidValidateTrxRes` containing: * `validation`: Security validation results including: * `classification`: Type of potential security issue * `description`: Detailed description of the validation results * `features`: Array of detected security features, each containing: * `type`: Feature type * `featureId`: Unique identifier * `description`: Feature description * `address`: Related address if applicable * `reason`: Explanation of the validation result * `resultType`: Type of result * `status`: Validation status * `simulation`: Transaction simulation results including: * `accountAddress`: Address being analyzed * `accountSummary`: Summary of account state changes * `addressDetails`: Detailed information about involved addresses * `assetsDiffs`: Asset balance changes, containing: * `asset`: Asset information * `in`: Incoming transfers * `out`: Outgoing transfers * `exposures`: Risk exposure analysis * `status`: Simulation status * `totalUsdDiff`: Total USD value change * `totalUsdExposure`: Total USD value at risk * `block`: Block number used for evaluation * `chain`: Chain identifier **Example Usage** ```swift theme={null} // Basic transaction evaluation do { let transaction = EvaluateTransactionParam( to: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", value: "1000000000000000000", // 1 ETH data: nil, maxFeePerGas: "30000000000", maxPriorityFeePerGas: "1500000000", gas: "21000", gasPrice: nil ) let evaluation = try await portal.evaluateTransaction( chainId: "eip155:1", transaction: transaction, operationType: .all ) // Check validation results if let validation = evaluation.validation { print("Security Status: \(validation.status)") if validation.status != "VALIDATED" { print("Warning: \(validation.description ?? "Unknown issue")") // Print detected security features validation.features.forEach { feature in print("Security Feature: \(feature.type)") print("Description: \(feature.description)") } } } // Check simulation results if let simulation = evaluation.simulation { print("Simulation Status: \(simulation.status)") // Print asset changes simulation.assetsDiffs.forEach { (token, diffs) in print("\nToken: \(token)") diffs.forEach { diff in print("Incoming transfers:") diff.in.forEach { transfer in print(transfer) } print("Outgoing transfers:") diff.out.forEach { transfer in print(transfer) } } } } } catch { print("Evaluation failed: \(error)") } // Contract interaction evaluation do { let transaction = EvaluateTransactionParam( to: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", value: "0", data: "0xa9059cbb000000000000000000000000742d35cc6634c0532925a3b844bc454e4438f44e0000000000000000000000000000000000000000000000000de0b6b3a7640000", // ERC20 transfer maxFeePerGas: nil, maxPriorityFeePerGas: nil, gas: "65000", gasPrice: "20000000000" ) // Validate only let validation = try await portal.evaluateTransaction( chainId: "eip155:1", transaction: transaction, operationType: .validation ) if let validationResult = validation.validation { // Handle potential security issues switch validationResult.status { case "VALIDATED": print("Transaction is safe") case "WARNING": print("Warning: \(validationResult.description ?? "Unknown warning")") case "BLOCKED": print("Blocked: \(validationResult.reason ?? "Unknown reason")") default: print("Unknown status: \(validationResult.status)") } } } catch { print("Validation failed: \(error)") } ``` **Implementation Notes** * Always evaluate transactions involving large amounts or contract interactions * Consider both validation and simulation results for comprehensive security * Handle different validation statuses appropriately in your UI * Gas parameters should be adjusted based on current network conditions * For contract interactions, carefully validate the data field # FirebaseStorage Source: https://docs.portalhq.io/sdks/ios/reference/firebasestorage Storage implementation for Firebase Auth Backup and recovery. **Class Definition** ```swift theme={null} public class FirebaseStorage: Storage, PortalStorage ``` `FirebaseStorage` handles storing and retrieving backup encryption keys using Firebase Authentication. It communicates with Portal's secure enclave using Firebase ID tokens for authentication. **Initializer** ```swift theme={null} public init( getToken: @escaping () async throws -> String? ) ``` **Parameters** * `getToken`: A callback that returns a fresh Firebase ID token. This is called before each request to the secure enclave to ensure the token is valid. The callback should call Firebase's `getIDToken()` method internally. Return `nil` if no user is signed in. **Errors** `FirebaseStorage` can throw the following errors: * `FirebaseStorageError.noApiKey`: The client API key has not been set on the `FirebaseStorage` instance. This means the storage was not registered via `portal.registerBackupMethod()`. * `FirebaseStorageError.tokenUnavailable`: The `getToken` callback returned `nil`. Ensure the user is signed in to Firebase before performing backup or recovery. * `FirebaseStorageError.unexpectedResponse(String)`: The secure enclave returned an unexpected response. * `FirebaseStorageError.requestFailed(underlying: Error)`: A non-401 request to the secure enclave failed. The underlying error contains details about the failure. **Example Usage** ```swift theme={null} import FirebaseAuth import PortalSwift // Create a FirebaseStorage instance let firebaseStorage = FirebaseStorage( getToken: { guard let user = Auth.auth().currentUser else { return nil } return try await user.getIDToken(forcingRefresh: true) } ) // Register it with Portal portal.registerBackupMethod(.Firebase, withStorage: firebaseStorage) // Now you can use .Firebase for backup and recovery let (cipherText, storageCallback) = try await portal.backupWallet(.Firebase) try await storageCallback() ``` **Related Documentation** * [Firebase Auth Backup](../guide/back-up-a-wallet#firebase-auth-backup) * [registerBackupMethod reference](./registerbackupmethod) * [backupWallet reference](./backupwallet) * [recoverWallet reference](./recoverwallet) # generateTraceId Source: https://docs.portalhq.io/sdks/ios/reference/generatetraceid Generates a lowercased UUID v4 for use as an X-Portal-Trace-Id value. **Function Signature** ```swift theme={null} public func generateTraceId() -> String ``` **Description** Generates a trace ID for request correlation. This is a free function on the `PortalSwift` module — it is not a method on `Portal`, so no wallet or client is required to call it. The returned value is a lowercased UUID v4. It is the same generator the SDK uses internally when you do not supply a trace ID, so IDs you generate with it match the format Portal's logs are indexed by. Pass the result to `RequestOptions(traceId:)`, `SendAssetParams(traceId:)`, or `portal.rawSign(message:chainId:signatureApprovalMemo:traceId:)` to have the SDK send it as the `X-Portal-Trace-Id` header. **Parameters** * None. **Returns** * `String` — a lowercased UUID v4, for example `"3f2b8a1c-9d4e-4c7f-b6a2-15e8d0c34f9b"`. **Throws** * Nothing. This function cannot fail. **Related Constant** ```swift theme={null} public let PORTAL_TRACE_ID_HEADER = "X-Portal-Trace-Id" ``` The header name the SDK uses when attaching a trace ID. It is exposed so you can reference the header in your own networking code or test assertions rather than hard-coding the string. **Example Usage** ```swift theme={null} import PortalSwift do { let portal = try Portal( "YOUR_CLIENT_API_KEY", withRpcConfig: ["eip155:11155111": "https://sepolia.infura.io/v3/YOUR_INFURA_KEY"] ) // Generate one trace ID for the whole user-facing action let traceId = generateTraceId() print("Sending — \(PORTAL_TRACE_ID_HEADER): \(traceId)") let response = try await portal.sendAsset( chainId: "eip155:11155111", params: SendAssetParams( to: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", amount: "0.01", token: "NATIVE", traceId: traceId ) ) print("Submitted — traceId: \(traceId), txHash: \(response.txHash)") } catch { print("Failed: \(error)") } ``` **Notes** * Generate one trace ID per user-facing action, not per request. Every request the operation makes reuses the ID you pass, which is what makes it useful for correlation. * If you omit a trace ID, the SDK generates one per request. Those IDs are never returned to you, so supply your own whenever you want to correlate with your logs. * MPC operations (generate, backup, recover, eject) and `upgradeTo7702` generate their own trace IDs and do not accept one. **Related Documentation** * [Request tracing](../guide/request-tracing) * [Send tokens](../guide/send-tokens) * [Configure log level](../guide/configure-log-level) # getAssets Source: https://docs.portalhq.io/sdks/ios/reference/getassets Retrieves a collection of assets (tokens and NFTs) for the specified blockchain. **Function Signature** ```swift theme={null} public func getAssets( _ chainId: String ) async throws -> AssetsResponse ``` **Parameters** * `chainId`: The chain identifier in CAIP-2 format (e.g., "eip155:1" for Ethereum mainnet, "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" for Solana mainnet) **Returns** An `AssetsResponse` object containing: * `nativeBalance`: Native token balance information (e.g., ETH for Ethereum) * `balance`: Current balance as string * `decimals`: Number of decimal places * `name`: Token name * `symbol`: Token symbol * `rawBalance`: Raw balance value * `metadata`: Contains token logo and thumbnail URLs * `tokenBalances`: Array of ERC20 token balances, each containing: * `balance`: Current balance as string * `decimals`: Number of decimal places * `name`: Token name * `symbol`: Token symbol * `rawBalance`: Raw balance value * `metadata`: Additional token information including: * `tokenAddress`: Contract address * `verifiedContract`: Contract verification status * `totalSupply`: Total token supply * `percentageRelativeToTotalSupply`: Holder's percentage of total supply * `nfts`: Array of NFT assets, each containing: * `nftID`: Unique identifier * `name`: NFT name * `description`: NFT description * `imageURL`: NFT image URL * `chainID`: Blockchain identifier * `contractAddress`: NFT contract address * `tokenID`: Token identifier * `collection`: Collection information * `lastSale`: Last sale information (price, currency, date) * `rarity`: Rarity information (rank and score) * `floorPrice`: Current floor price information * `detailedInfo`: Extended NFT information including: * Ownership details * Collection statistics * Marketplace information * Media previews and metadata **Example Usage** ```swift theme={null} // Fetch and display all assets do { let assets = try await portal.getAssets("eip155:1") // Display native token balance if let native = assets.nativeBalance { print("Native Token:") print("Name: \(native.name ?? "Unknown")") print("Balance: \(native.balance ?? "0")") print("Symbol: \(native.symbol ?? "?")") } // Display ERC20 token balances print("\nERC20 Tokens:") assets.tokenBalances?.forEach { token in guard let name = token.name, let symbol = token.symbol, let balance = token.balance else { return } print("\(name) (\(symbol)): \(balance)") } // Display NFTs with detailed information print("\nNFTs:") assets.nfts?.forEach { nft in print("\nNFT: \(nft.name ?? "Unnamed")") print("ID: \(nft.nftID ?? "Unknown")") if let lastSale = nft.lastSale, let price = lastSale.price, let currency = lastSale.currency { print("Last Sale: \(price) \(currency)") } if let floorPrice = nft.floorPrice, let price = floorPrice.price, let currency = floorPrice.currency { print("Floor Price: \(price) \(currency)") } // Display marketplace information nft.detailedInfo?.marketplaceInfo?.forEach { marketplace in if let name = marketplace.marketplaceName { print("Listed on: \(name)") } } } } catch { print("Error fetching assets: \(error)") } // Example with portfolio value calculation do { let assets = try await portal.getAssets("eip155:1") var totalValue = 0.0 // Add native token value if let nativeBalance = assets.nativeBalance?.balance, let nativePrice = await getCurrentPrice(symbol: assets.nativeBalance?.symbol ?? "ETH"), let balance = Double(nativeBalance) { totalValue += balance * nativePrice } // Add ERC20 token values assets.tokenBalances?.forEach { token in if let balance = token.balance, let symbol = token.symbol, let price = await getCurrentPrice(symbol: symbol), let balanceValue = Double(balance) { totalValue += balanceValue * price } } // Add NFT values using floor prices assets.nfts?.forEach { nft in if let floorPrice = nft.floorPrice?.price { totalValue += floorPrice } } print("Total Portfolio Value: $\(String(format: "%.2f", totalValue))") } catch { print("Error calculating portfolio value: \(error)") } ``` **Implementation Notes** * All balance fields are optional and should be safely unwrapped * Token balances are returned as strings to maintain precision * NFT data includes extensive metadata useful for marketplace integration # getBalances Source: https://docs.portalhq.io/sdks/ios/reference/getbalances Retrieves token balances for the specified blockchain. This method fetches all token balances (native and ERC-20 or SPL) associated with the wallet on the specified blockchain network. **Function Signature** ```swift theme={null} public func getBalances( _ chainId: String ) async throws -> [FetchedBalance] ``` **Parameters** * `chainId`: The chain identifier in CAIP-2 format (e.g., "eip155:1" for Ethereum mainnet, "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" for Solana mainnet) **Returns** An array of `FetchedBalance` objects containing token balance information. Each object includes: * `contractAddress`: The contract address of the token * `balance`: The balance of the token as a string * `name`: Optional token name * `symbol`: Optional token symbol **Throws** Various API-related errors if the balance retrieval fails **Example Usage** ```swift theme={null} // Basic balance check do { // Get Ethereum balances let ethBalances = try await portal.getBalances("eip155:1") for balance in ethBalances { print("Token: \(balance.symbol)") print("Balance: \(balance.balance)") print("Decimals: \(balance.decimals)") } // Get Solana balances let solBalances = try await portal.getBalances("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp") for balance in solBalances { print("Token: \(balance.symbol)") print("Balance: \(balance.balance)") } } catch { print("Error fetching balances: \(error)") } // Complete example with balance formatting do { let balances = try await portal.getBalances("eip155:1") // Format and display balances for balance in balances { // Parse and format the balance string if let balanceValue = Double(balance.balance) { // Format with appropriate decimal places let formatter = NumberFormatter() formatter.minimumFractionDigits = 2 formatter.maximumFractionDigits = 6 if let formatted = formatter.string(from: NSNumber(value: balanceValue)), let symbol = balance.symbol { print("\(symbol): \(formatted)") } } } } catch { print("Error processing balances: \(error)") } // Example with balance monitoring class BalanceMonitor { private var timer: Timer? private let portal: Portal private let chainId: String init(portal: Portal, chainId: String) { self.portal = portal self.chainId = chainId } func startMonitoring() { // Check balances every 30 seconds timer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in Task { await self?.checkBalances() } } } private func checkBalances() async { do { let balances = try await portal.getBalances(chainId) // Process and display balances for balance in balances { if balance.balance > 0 { print("New balance for \(balance.symbol): \(balance.balance)") } } } catch { print("Balance check failed: \(error)") } } func stopMonitoring() { timer?.invalidate() timer = nil } } ``` **Implementation Notes** * Token balances are returned in their smallest unit (e.g., wei for ETH) * Need to consider token decimals when displaying balances * Method returns all tokens with non-zero balances * Consider rate limiting when polling balances frequently * Currently, EVM and Solana chains are supported by this method. **Usage in Portfolio Tracking** ```swift theme={null} func calculatePortfolioValue(balances: [FetchedBalance], prices: [String: Double]) -> Double { return balances.reduce(0) { total, balance in guard let symbol = balance.symbol, let balanceValue = Double(balance.balance) else { return total } return total + (balanceValue * (prices[symbol] ?? 0)) } } // Usage example do { let balances = try await portal.getBalances("eip155:1") let prices = try await fetchCurrentPrices() // Your price fetching implementation let portfolioValue = calculatePortfolioValue(balances: balances, prices: prices) print("Total portfolio value: $\(portfolioValue)") } catch { print("Error calculating portfolio value: \(error)") } ``` # getNftAssets Source: https://docs.portalhq.io/sdks/ios/reference/getnftassets Retrieves NFT assets for the specified blockchain. This method fetches all NFTs (Non-Fungible Tokens) owned by the wallet. **Function Signature** ```swift theme={null} public func getNftAssets( _ chainId: String ) async throws -> [NftAsset] ``` **Parameters** * `chainId`: The chain identifier in CAIP-2 format (e.g., "eip155:1" for Ethereum mainnet, "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" for Solana mainnet) **Returns** An array of `NftAsset` objects, each containing: * `nftID`: Unique identifier for the NFT * `name`: NFT name * `description`: NFT description * `imageURL`: URL of the NFT image * `chainID`: Blockchain identifier * `contractAddress`: NFT contract address * `tokenID`: Token identifier * `collection`: Collection information * `lastSale`: Last sale details containing: * `price`: Sale price * `currency`: Currency used * `date`: Sale date * `rarity`: Rarity information: * `rank`: Rarity rank * `score`: Rarity score * `floorPrice`: Current floor price information: * `price`: Floor price value * `currency`: Currency * `detailedInfo`: Extended NFT information including: * `ownerCount`: Number of owners * `tokenCount`: Total tokens * `createdDate`: Creation date * `attributes`: NFT attributes * `owners`: Ownership details * `extendedCollectionInfo`: Collection metadata * `extendedSaleInfo`: Sale history * `marketplaceInfo`: Marketplace listings * `mediaInfo`: Media preview URLs and metadata **Example Usage** ```swift theme={null} // Basic NFT fetch and display do { let nfts = try await portal.getNftAssets("eip155:1") print("Your NFT Collection:") for nft in nfts { print("\nNFT: \(nft.name ?? "Unnamed")") if let description = nft.description { print("Description: \(description)") } if let imageURL = nft.imageURL { print("Image: \(imageURL)") } // Display rarity information if available if let rarity = nft.rarity { if let rank = rarity.rank { print("Rarity Rank: #\(rank)") } if let score = rarity.score { print("Rarity Score: \(score)") } } // Display current market value if let floor = nft.floorPrice, let price = floor.price, let currency = floor.currency { print("Floor Price: \(price) \(currency)") } } } catch { print("Error fetching NFTs: \(error)") } // Example with marketplace information do { let nfts = try await portal.getNftAssets("eip155:1") for nft in nfts { print("\nNFT: \(nft.name ?? "Unnamed")") // Display marketplace listings if let marketplaceInfo = nft.detailedInfo?.marketplaceInfo { print("Listed on:") for listing in marketplaceInfo { if let name = listing.marketplaceName, let floorPrice = listing.floorPrice, let price = floorPrice.value, let token = floorPrice.paymentToken { print("- \(name): \(price) \(token.symbol ?? "")") if let usdValue = floorPrice.valueUsdCents { print(" USD Value: $\(Double(usdValue) / 100)") } } } } // Display media previews if let previews = nft.detailedInfo?.mediaInfo?.previews { print("\nMedia Previews:") if let small = previews.imageSmallURL { print("Small: \(small)") } if let medium = previews.imageMediumURL { print("Medium: \(medium)") } if let large = previews.imageLargeURL { print("Large: \(large)") } } } } catch { print("Error processing NFTs: \(error)") } // Example with collection statistics do { let nfts = try await portal.getNftAssets("eip155:1") // Group NFTs by collection let collections = Dictionary(grouping: nfts) { $0.collection?.name ?? "Unknown Collection" } for (collectionName, collectionNFTs) in collections { print("\nCollection: \(collectionName)") if let firstNFT = collectionNFTs.first, let collectionInfo = firstNFT.detailedInfo?.extendedCollectionInfo { print("Statistics:") print("Distinct Owners: \(collectionInfo.distinctOwnerCount ?? 0)") print("Distinct NFTs: \(collectionInfo.distinctNftCount ?? 0)") print("Total Supply: \(collectionInfo.totalQuantity ?? 0)") // Display social links var socials: [String] = [] if let twitter = collectionInfo.twitterUsername { socials.append("Twitter: @\(twitter)") } if let discord = collectionInfo.discordURL { socials.append("Discord: \(discord)") } if let instagram = collectionInfo.instagramUsername { socials.append("Instagram: @\(instagram)") } if !socials.isEmpty { print("\nSocial Links:") socials.forEach { print($0) } } } // Calculate collection value let totalValue = collectionNFTs.compactMap { $0.floorPrice?.price }.reduce(0, +) if totalValue > 0 { print("\nEstimated Collection Value: \(totalValue)") } } } catch { print("Error analyzing collections: \(error)") } ``` **Implementation Notes** * All fields in the NFT response are optional and should be safely unwrapped * The method provides rich metadata useful for marketplace integration * Floor prices and sale history can be used for portfolio valuation * Media previews include multiple resolutions for efficient loading # getTransactions Source: https://docs.portalhq.io/sdks/ios/reference/gettransactions Retrieves transaction history for the specified blockchain. **Function Signature** ```swift theme={null} public func getTransactions( _ chainId: String, limit: Int? = nil, offset: Int? = nil, order: TransactionOrder? = nil ) async throws -> [FetchedTransaction] ``` **Parameters** * `chainId`: The chain identifier in CAIP-2 format (e.g., "eip155:1" for Ethereum mainnet, "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" for Solana mainnet) * `limit`: Optional maximum number of transactions to return. If nil, returns all transactions. * `offset`: Optional number of transactions to skip for pagination. If nil, starts from the beginning. * `order`: Optional `TransactionOrder` to specify the sort order of transactions: `ASC` or `DESC`. **Returns** An array of `FetchedTransaction` objects, each containing: * `blockNum`: Block number in which the transaction was included * `uniqueId`: Unique identifier of the transaction * `hash`: Hash of the transaction * `from`: Address that initiated the transaction * `to`: Address that the transaction was sent to * `value`: Optional value transferred in the transaction as Float * `erc721TokenId`: Optional token ID for ERC721 NFT transactions * `erc1155Metadata`: Optional array of ERC1155 token metadata, each containing: * `tokenId`: Token identifier * `value`: Token value * `tokenId`: Optional general token identifier * `asset`: Optional type of asset involved (e.g., "ETH") * `category`: Category of the transaction (e.g., "external") * `rawContract`: Optional contract details containing: * `value`: Contract value * `address`: Contract address * `decimal`: Decimal representation of the contract value * `metadata`: Transaction metadata containing: * `blockTimestamp`: ISO format timestamp of the block * `chainId`: ID of the chain associated with the transaction **Example Usage** ```swift theme={null} // Basic transaction fetch do { let transactions = try await portal.getTransactions( "eip155:1", limit: 10, order: .DESC ) for tx in transactions { print("\nTransaction: \(tx.hash)") print("Block: \(tx.blockNum)") print("From: \(tx.from)") print("To: \(tx.to)") if let value = tx.value { print("Value: \(value)") } if let asset = tx.asset { print("Asset: \(asset)") } print("Time: \(tx.metadata.blockTimestamp)") // Handle NFT transfers if let erc721Id = tx.erc721TokenId { print("NFT Token ID: \(erc721Id)") } if let erc1155Data = tx.erc1155Metadata { print("ERC1155 Transfers:") for metadata in erc1155Data { if let tokenId = metadata?.tokenId, let value = metadata?.value { print("Token ID: \(tokenId), Amount: \(value)") } } } // Contract interaction details if let contract = tx.rawContract { print("\nContract Details:") if let address = contract.address { print("Contract Address: \(address)") } if let value = contract.value { print("Contract Value: \(value)") } } } } catch { print("Error fetching transactions: \(error)") } // Example with transaction analysis do { let transactions = try await portal.getTransactions("eip155:1") // Group transactions by category let groupedTxs = Dictionary(grouping: transactions) { $0.category } for (category, txs) in groupedTxs { print("\nCategory: \(category)") print("Count: \(txs.count)") // Calculate total value for the category let totalValue = txs.compactMap { $0.value }.reduce(0, +) print("Total Value: \(totalValue)") // Count token transfers let nftTransfers = txs.filter { $0.erc721TokenId != nil }.count print("NFT Transfers: \(nftTransfers)") let erc1155Transfers = txs.filter { $0.erc1155Metadata != nil }.count print("ERC1155 Transfers: \(erc1155Transfers)") } } catch { print("Error analyzing transactions: \(error)") } // Example with pagination and date filtering func loadTransactions(fromDate: Date) async { let dateFormatter = ISO8601DateFormatter() var offset = 0 let pageSize = 20 var hasMore = true while hasMore { do { let txs = try await portal.getTransactions( "eip155:1", limit: pageSize, offset: offset, order: .DESC ) // Filter transactions by date for tx in txs { let txDate = dateFormatter.date(from: tx.metadata.blockTimestamp) if let date = txDate, date >= fromDate { print("Processing transaction: \(tx.hash)") } else { hasMore = false break } } if txs.count < pageSize { hasMore = false } else { offset += pageSize } } catch { print("Error loading transactions: \(error)") hasMore = false } } } ``` **Implementation Notes** * Transaction values and timestamps should be carefully parsed * Consider implementing caching for recent transactions * The `uniqueId` field can be used for deduplication * ERC721 and ERC1155 transfers include additional token metadata * Block timestamps are in ISO format and need proper parsing # getWalletCapabilities Source: https://docs.portalhq.io/sdks/ios/reference/getwalletcapabilities Retrieves the capabilities and supported features of the current wallet, particularly focusing on available services like paymaster support. **Function Signature** ```swift theme={null} public func getWalletCapabilities() async throws -> WalletCapabilitiesResponse ``` **Parameters** None - This function doesn't require any parameters. **Returns** A `WalletCapabilitiesResponse` dictionary containing: * Chain identifiers mapped to `WalletCapabilitiesValue` objects * Each `WalletCapabilitiesValue` includes: * `paymasterService`: Information about paymaster support * `supported`: Boolean indicating if paymaster service is available **Example Usage** ```swift theme={null} // Basic capabilities check do { let capabilities = try await portal.getWalletCapabilities() // Check capabilities for Ethereum mainnet if let ethCapabilities = capabilities["eip155:1"] { if ethCapabilities.paymasterService.supported { // Handle supported paymaster print("Paymaster service available") proceedWithGaslessTransaction() } else { // Handle unsupported paymaster print("Regular transaction required") proceedWithRegularTransaction() } } // Check capabilities for multiple chains let chainIds = [ "eip155:1", // Ethereum "eip155:137", // Polygon "eip155:56" // BSC ] for chainId in chainIds { if let chainCapabilities = capabilities[chainId] { print("\(chainId) Capabilities:") print("Paymaster supported: \(chainCapabilities.paymasterService.supported)") } } } catch { print("Failed to fetch capabilities: \(error)") } // Feature-gated transaction handling do { let capabilities = try await portal.getWalletCapabilities() guard let chainCapabilities = capabilities["eip155:1"], chainCapabilities.paymasterService.supported else { throw WalletError.paymasterNotSupported } // Proceed with paymaster-dependent transaction await processGaslessTransaction() } catch { // Handle unsupported feature switch error { case WalletError.paymasterNotSupported: print("Paymaster service not available") await requestGasFromUser() default: print("Unexpected error: \(error)") } } ``` **Implementation Notes** 1. Chain Support * Check capabilities per chain before feature-dependent operations * Handle missing chain capabilities gracefully * Verify feature support before initiating operations 2. Error Handling * Handle network failures when fetching capabilities * Verify capability existence before access * Consider fallback options for unsupported features 3. Capability Changes * Capabilities might change during runtime * Consider refreshing capabilities periodically * Don't cache capabilities for extended periods # isWalletBackedUp Source: https://docs.portalhq.io/sdks/ios/reference/iswalletbackedup Checks whether a wallet has completed backups. This method verifies if backup shares have been successfully created and stored. **Function Signature** ```swift theme={null} public func isWalletBackedUp( _ forChainId: String? = nil ) async throws -> Bool ``` **Parameters** * `forChainId`: Optional chain identifier (e.g., "eip155:1" for Ethereum mainnet, "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" for Solana mainnet). If nil, checks backup status across all chains. **Returns** A boolean value indicating whether the wallet is backed up: * When forChainId is provided: * Returns `true` if a wallet exists for the specified chain and has at least one completed backup share * Returns `false` if no wallet exists or if no backup shares are completed * When forChainId is `nil`: * Returns `true` if any wallet exists and has at least one completed backup share * Returns `false` if no wallets exist or if no backup shares are completed **Throws** * `PortalClassError.clientNotAvailable` if the client is not initialized **Example Usage** ```swift theme={null} // Check backup status for all wallets do { let isBackedUp = try await portal.isWalletBackedUp() if isBackedUp { print("At least one wallet is backed up") } else { print("No wallets are backed up") } } catch { print("Error checking backup status: \(error)") } // Check backup status for specific chains do { // Check Ethereum wallet backup let ethBackedUp = try await portal.isWalletBackedUp("eip155:1") if ethBackedUp { print("Ethereum wallet is backed up") } // Check Solana wallet backup let solBackedUp = try await portal.isWalletBackedUp("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp") if solBackedUp { print("Solana wallet is backed up") } } catch { print("Error checking chain-specific backups: \(error)") } // Complete example with backup flow do { let isBackedUp = try await portal.isWalletBackedUp() if !isBackedUp { // Perform backup if not already done let backup = try await portal.backupWallet(.iCloud) { status in switch status.status { case .readingShare: print("Reading share...") case .encryptingShare: print("Encrypting share...") case .storingShare: print("Storing backup...") case .done: print("Backup completed!") default: break } } // Complete the backup process try await backup.storageCallback() // Verify backup was successful let verifyBackup = try await portal.isWalletBackedUp() print("Backup verification: \(verifyBackup ? "Success" : "Failed")") } else { print("Wallet is already backed up") } } catch { print("Error in backup process: \(error)") } ``` **Implementation Notes** * The method only considers backup shares with a status of `.completed` * Incomplete or pending backups are not considered when determining backup status * Useful for ensuring wallet security and prompting users to complete backup * Should be checked before performing critical operations **Usage in App Flow** 1. After Wallet Creation: ```swift theme={null} // Check backup status after creating wallet let wallet = try await portal.createWallet() if !(try await portal.isWalletBackedUp()) { // Prompt user to backup their wallet showBackupPrompt() } ``` 2. Security Checks: ```swift theme={null} // Ensure wallet is backed up before large transactions func performTransaction() async throws { guard try await portal.isWalletBackedUp() else { throw CustomError.walletNotBackedUp } // Proceed with transaction } ``` **Related Documentation** For more information about wallet backups, see: * [Back up a wallet](../guide/back-up-a-wallet) # isWalletOnDevice Source: https://docs.portalhq.io/sdks/ios/reference/iswalletondevice Checks whether wallet shares are present in the device's keychain. This method verifies if the signing shares required for wallet operations are stored in the device's local keychain. **Function Signature** ```swift theme={null} public func isWalletOnDevice( _ forChainId: String? = nil ) async throws -> Bool ``` **Parameters** * `forChainId`: Optional chain identifier (e.g., "eip155:1" for Ethereum mainnet, "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" for Solana mainnet). If nil, checks for any valid shares across all chains. **Returns** A boolean value indicating whether wallet shares exist on the device: * When forChainId is provided: * Returns `true` if shares exist for the specified chain * Returns `false` if no shares are found for the chain * When forChainId is nil: * Returns `true` if any valid share exists (has non-empty ID) * Returns `false` if no valid shares are found **Throws** * `PortalClassError.invalidChainId` if the provided chain ID format is invalid * `PortalClassError.unsupportedChainId` if the chain's namespace is not supported * Various keychain-related errors if share retrieval fails **Example Usage** ```swift theme={null} // Check for any wallet on device do { let isOnDevice = try await portal.isWalletOnDevice() if isOnDevice { print("Wallet shares found on device") } else { print("No wallet shares on device") } } catch { print("Error checking wallet presence: \(error)") } // Check for specific chain wallets do { // Check Ethereum wallet let ethOnDevice = try await portal.isWalletOnDevice("eip155:1") if ethOnDevice { print("Ethereum wallet shares found on device") } // Check Solana wallet let solOnDevice = try await portal.isWalletOnDevice("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp") if solOnDevice { print("Solana wallet shares found on device") } } catch { print("Error checking chain-specific wallets: \(error)") } // Complete example with recovery flow do { let isOnDevice = try await portal.isWalletOnDevice() if !isOnDevice { // Check if wallet exists and could be used to recover the wallet if try await portal.isWalletRecoverable() { // Recover wallet using available backup method let methods = try await portal.availableRecoveryMethods() if methods.contains(.iCloud) { let recovered = try await portal.recoverWallet(.iCloud) print("Recovered wallet with address: \(recovered.ethereum)") } } else { // Create new wallet if none exists let newWallet = try await portal.createWallet() print("Created new wallet with ETH address: \(newWallet.ethereum)") } } else { // Wallet is on device, verify it's working let addresses = try await portal.getAddresses() if let ethAddress = addresses[.eip155] { print("Active ETH address: \(ethAddress ?? "Not found")") } } } catch { print("Error in wallet management flow: \(error)") } ``` **Implementation Notes** * This method checks for the physical presence of shares on the device, regardless of their status * Different from `doesWalletExist()` which checks for completed shares on the server * Useful for determining whether to trigger wallet recovery or creation flows * The check is performed against the device's keychain storage **Usage in App Flow** 1. App Launch Check: ```swift theme={null} func checkWalletStatus() async { if try await portal.isWalletOnDevice() { // Wallet is present, proceed to main app showMainInterface() } else { // No wallet on device if try await portal.isWalletRecoverable() { showRecoveryInterface() } else { showCreateWalletInterface() } } } ``` 2. Device Migration: ```swift theme={null} func handleDeviceMigration() async { guard try await portal.isWalletBackedUp() else { throw MigrationError.walletNotBackedUp } if try await portal.isWalletOnDevice() { // Wallet already on this device return } // Proceed with recovery on new device let recovered = try await portal.recoverWallet(.iCloud) print("Migrated wallet to new device") } ``` **Related Documentation** For more information about wallet storage and device management, see: * [Cross-device sessions](../guide/cross-device-sessions) # isWalletRecoverable Source: https://docs.portalhq.io/sdks/ios/reference/iswalletrecoverable Checks whether the wallet can be recovered using any available backup methods. This method verifies if there are any completed backup methods that could be used to recover the wallet. **Function Signature** ```swift theme={null} public func isWalletRecoverable( _ forChainId: String? = nil ) async throws -> Bool ``` **Parameters** * `forChainId`: Optional CAIP-2 chain identifier (e.g., "eip155:1" for Ethereum mainnet, "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" for Solana mainnet). If nil, checks recovery options across all chains. **Returns** A boolean value indicating whether the wallet can be recovered: * Returns `true` if at least one completed backup method is available * Returns `false` if no completed backup methods are found **Throws** * `PortalClassError.clientNotAvailable` if the client is not initialized * `PortalClassError.unsupportedChainId` if the chain's namespace is not supported * `PortalClassError.noWalletFoundForChain` if no wallet exists for the specified chain **Example Usage** ```swift theme={null} // Check if any wallet is recoverable do { let isRecoverable = try await portal.isWalletRecoverable() if isRecoverable { print("Wallet can be recovered") } else { print("No recovery methods available") } } catch { print("Error checking recovery status: \(error)") } // Check specific chain wallet recovery do { // Check Ethereum wallet let ethRecoverable = try await portal.isWalletRecoverable("eip155:1") if ethRecoverable { print("Ethereum wallet can be recovered") } // Check Solana wallet let solRecoverable = try await portal.isWalletRecoverable("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp") if solRecoverable { print("Solana wallet can be recovered") } } catch { print("Error checking chain-specific recovery: \(error)") } // Complete example with recovery flow do { if try await portal.isWalletRecoverable() { // Get available recovery methods let methods = try await portal.availableRecoveryMethods() // Try each available method for method in methods { do { let recovered = try await portal.recoverWallet(method) { status in print("Recovery status: \(status)") } print("Successfully recovered wallet using \(method)") print("Ethereum address: \(recovered.ethereum)") if let solanaAddress = recovered.solana { print("Solana address: \(solanaAddress)") } break // Exit after successful recovery } catch { print("Failed to recover using \(method): \(error)") continue // Try next method } } } else { print("No recovery methods available, need to create new wallet") let newWallet = try await portal.createWallet() print("Created new wallet") } } catch { print("Error in recovery process: \(error)") } ``` **Implementation Notes** * This method uses `availableRecoveryMethods()` internally to determine if any completed backup methods exist * Only considers backup methods that have been fully completed and verified * Useful for determining whether to show recovery options to users * Should be checked before attempting wallet recovery **Usage in App Flow** 1. Initial Setup Check: ```swift theme={null} func checkWalletStatus() async { if try await portal.isWalletOnDevice() { showMainInterface() } else if try await portal.isWalletRecoverable() { showRecoveryInterface() } else { showCreateWalletInterface() } } ``` 2. Recovery Process: ```swift theme={null} func handleRecovery() async { guard try await portal.isWalletRecoverable() else { throw WalletError.notRecoverable } let methods = try await portal.availableRecoveryMethods() showRecoveryOptions(methods) } ``` **Related Documentation** For more information about wallet recovery, see: * [Recover a wallet](../guide/recover-a-wallet) * [Cross-device sessions](../guide/cross-device-sessions) # pollStatus Source: https://docs.portalhq.io/sdks/ios/reference/lifipollstatus Polls Li.Fi for the status of a transfer until it reaches a terminal state. **Function Signature** Four callable forms. The first is the protocol requirement, which takes all three arguments; the other three are convenience overloads on `LifiProtocol` that supply defaults. ```swift theme={null} // All three arguments public func pollStatus( request: LifiStatusRequest, onUpdate: ((LifiStatusRawResponse) -> Bool)?, options: LifiPollStatusOptions ) async throws -> LifiStatusRawResponse // Default options, no callback public func pollStatus(request: LifiStatusRequest) async throws -> LifiStatusRawResponse // Custom options, no callback public func pollStatus(request: LifiStatusRequest, options: LifiPollStatusOptions) async throws -> LifiStatusRawResponse // Callback, default options public func pollStatus( request: LifiStatusRequest, onUpdate: @escaping (LifiStatusRawResponse) -> Bool ) async throws -> LifiStatusRawResponse ``` **Description** Polls the Li.Fi status endpoint until the transfer reaches a terminal state, then returns the final status. This is the same polling `tradeAsset` performs internally after each step confirms, exposed for manual flows where you already hold a transaction hash. Returning `false` from `onUpdate` stops polling early and returns the last status received — it is not an error. Returning `true` continues polling. `onUpdate` only fires on non-terminal polls. A `DONE` status returns immediately and a `FAILED` status throws, so neither reaches the callback — read the terminal state from the return value rather than from `onUpdate`. **Parameters** The Required column describes the three-argument protocol requirement, where neither `onUpdate` nor `options` carries a default. The convenience overloads supply both, which is why most call sites pass only `request`. | Parameter | Type | Required | Description | | ---------- | ------------------------------------ | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `request` | `LifiStatusRequest` | Yes | Identifies the transfer — `txHash`, `fromChain`, `toChain`, and an optional `bridge`. | | `onUpdate` | `((LifiStatusRawResponse) -> Bool)?` | Yes on the protocol requirement; defaulted by the overloads | Called on each non-terminal poll with the latest status. Return `false` to stop early. Pass `nil` on the three-argument form to skip it. | | `options` | `LifiPollStatusOptions` | Yes on the protocol requirement; defaulted by the overloads | Polling cadence and timeout. | `LifiPollStatusOptions`: | Option | Type | Default | Description | | ---------------- | ----- | -------- | -------------------------------------------------------------------------------------------------------- | | `everyMs` | `Int` | `10000` | Interval between polls, in milliseconds. Values below `100` are raised to `100` to prevent busy-waiting. | | `initialDelayMs` | `Int` | `0` | Delay before the first poll, in milliseconds. | | `timeoutMs` | `Int` | `600000` | Overall polling timeout, in milliseconds. | **Returns** **`LifiStatusRawResponse`** — the final status payload from Li.Fi, or the last one received if `onUpdate` returned `false`. **Throws** | Case | When | | ------------------------------------------------ | ----------------------------------------- | | `LifiTradeAssetError.pollTimeout` | Polling exceeded `timeoutMs`. | | `LifiTradeAssetError.lifiTransferFailed(String)` | Li.Fi reported a `FAILED` terminal state. | Also throws `CancellationError` if the surrounding `Task` is cancelled. **Example Usage** ```swift theme={null} import PortalSwift do { let txHash = "0xYOUR_TRANSACTION_HASH" let final = try await portal.trading.lifi.pollStatus( request: LifiStatusRequest( txHash: txHash, fromChain: "eip155:8453", toChain: "eip155:42161" ), onUpdate: { update in print("Status: \(update.status.rawValue)") return true // return false to stop polling early } ) print("Final status: \(final.status.rawValue)") } catch LifiTradeAssetError.pollTimeout { print("Timed out waiting for the transfer to settle") } catch { print("pollStatus failed: \(error)") } ``` With custom cadence: ```swift theme={null} let txHash = "0xYOUR_TRANSACTION_HASH" let final = try await portal.trading.lifi.pollStatus( request: LifiStatusRequest(txHash: txHash, fromChain: "eip155:8453", toChain: "eip155:42161"), options: LifiPollStatusOptions(everyMs: 5_000, initialDelayMs: 10_000, timeoutMs: 300_000) ) ``` **Related Documentation** * [Bridge & Swap with Li.Fi](../guide/lifi) * [tradeAsset](./lifitradeasset) * [Li.Fi Integration](/integrations/Trading/lifi) # tradeAsset Source: https://docs.portalhq.io/sdks/ios/reference/lifitradeasset Runs an end-to-end Li.Fi bridge or swap in a single call. **Function Signature** ```swift theme={null} public func tradeAsset(params: LifiTradeAssetParams) async throws -> LifiTradeAssetResult ``` **Description** Executes a complete Li.Fi bridge or swap: discovers routes, selects one, then for each step builds the transaction, signs and broadcasts it, waits for on-chain confirmation, and polls Li.Fi until that step reaches a terminal state before moving to the next. Steps run **sequentially**. Signing and confirmation for each step happen on that step's own chain, resolved from the step itself, so a multi-chain route is handled without the caller switching networks. Confirmation is strict — every step must confirm on-chain before the next begins. A revert or a confirmation timeout aborts the trade and throws. There is no optimistic fallback. Unlike the React Native and Web SDKs, there is no second options argument. The signer and confirmation hooks are injected when the `Lifi` instance is constructed, and `Portal` wires both automatically. **Parameters** `LifiTradeAssetParams`: | Parameter | Type | Required | Description | | ------------- | ---------------------- | --------------- | ----------------------------------------------------------------------------------------- | | `fromChain` | `String` | Yes | Source chain, CAIP-2 (`"eip155:8453"`). | | `toChain` | `String` | Yes | Destination chain, CAIP-2. | | `fromToken` | `String` | Yes | Source token contract address or symbol. | | `toToken` | `String` | Yes | Destination token contract address or symbol. | | `amount` | `String` | Yes | Amount in the token's base units, as an integer string. | | `fromAddress` | `String?` | No, but pass it | Sending wallet address. Forwarded to Li.Fi as given; the SDK does not resolve it for you. | | `toAddress` | `String?` | No | Receiving wallet address. Falls back to `fromAddress`. | | `routeIndex` | `Int?` | No | Which discovered route to execute. Default `0`. | | `onProgress` | `LifiProgressHandler?` | No | Callback fired at each stage of execution. | Omitting `fromAddress` means routes are quoted without a sender while the transaction is still signed by your Portal wallet, so the quote may not match what executes. Pass it explicitly. **Returns** **`LifiTradeAssetResult`**: | Property | Type | Description | | -------- | ------------ | --------------------------------------------------------------------------- | | `hashes` | `[String]` | One transaction hash per executed step, in execution order. | | `steps` | `[LifiStep]` | The enriched steps that were executed, with `transactionRequest` populated. | | `route` | `LifiRoute` | The route that was selected and executed. | **Throws** Throws `LifiTradeAssetError`: | Case | When | | ----------------------------------------- | -------------------------------------------------------------------------------------- | | `missingSigner` | No signing closure on the `Lifi` instance. Cannot occur on `portal.trading.lifi`. | | `missingConfirmation` | No confirmation closure on the `Lifi` instance. Cannot occur on `portal.trading.lifi`. | | `noRoutesFound` | Li.Fi returned no routes for the requested trade. | | `routeIndexOutOfBounds` | `routeIndex` is negative or beyond the number of discovered routes. | | `routeHasNoSteps` | The selected route contains no steps. | | `missingTransactionRequest` | A step came back without a transaction request to sign. | | `invalidTransactionRequest` | A step's transaction request was malformed. | | `transactionConfirmationFailed(String)` | The step's transaction reverted on-chain. Carries the transaction hash. | | `transactionConfirmationTimedOut(String)` | Confirmation could not be determined before timing out. Carries the transaction hash. | | `lifiTransferFailed(String)` | Li.Fi reported a `FAILED` terminal state. | | `pollTimeout` | Status polling exceeded the configured timeout. | Also throws `CancellationError` if the surrounding `Task` is cancelled. In that case **no `.failed` progress event is emitted** — handle cancellation separately from failure. **Example Usage** ```swift theme={null} import PortalSwift do { let fromAddress = try await portal.getAddress("eip155:8453") let result = try await portal.trading.lifi.tradeAsset( params: LifiTradeAssetParams( fromChain: "eip155:8453", toChain: "eip155:42161", fromToken: "ETH", toToken: "USDC", amount: "1000000000000", fromAddress: fromAddress, onProgress: { status, data in print("[Li.Fi] \(status.rawValue) \(data.txHash ?? "")") } ) ) print("Hashes: \(result.hashes)") print("Executed steps: \(result.steps.count)") } catch let LifiTradeAssetError.transactionConfirmationFailed(txHash) { print("Transaction reverted on-chain: \(txHash)") } catch { print("tradeAsset failed: \(error)") } ``` **Related Documentation** * [Bridge & Swap with Li.Fi](../guide/lifi) * [pollStatus](./lifipollstatus) * [Li.Fi Integration](/integrations/Trading/lifi) # getPaymentMethods Source: https://docs.portalhq.io/sdks/ios/reference/noahgetpaymentmethods Lists the stored Noah payment methods for the current customer. ## Function Signature ```swift theme={null} public func getPaymentMethods(request: NoahGetPaymentMethodsRequest) async throws -> NoahGetPaymentMethodsResponse ``` A no-argument convenience overload is provided on `NoahProtocol` for calling with default request values: ```swift theme={null} public func getPaymentMethods() async throws -> NoahGetPaymentMethodsResponse ``` ## Description Returns the payment methods available to the current customer — cards, bank rails, or identifier-based rails — with a pagination token when more results exist. All request parameters are optional. Omitting them lets the server fall back to its own defaults. ## Parameters | Parameter | Type | Required | Description | | -------------------- | ------------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `request.pageSize` | `Int?` | No | Maximum number of payment methods to return. Must be an integer between 1 and 100 — validated server-side, and out-of-range values are rejected with a `400` rather than clamped. | | `request.pageToken` | `String?` | No | Cursor from a previous response's `data.pageToken`. | | `request.capability` | `NoahPaymentMethodCapability?` | No | Filter by what the method can be used for — `.payoutFrom`, `.payinTo`, or `.payoutTo`. | ## Returns **`NoahGetPaymentMethodsResponse`** — an envelope containing: | Property | Type | Description | | ---------- | --------------------------- | --------------------------- | | `data` | `NoahGetPaymentMethodsData` | The response payload. | | `metadata` | `NoahResponseMetadata?` | Freeform response metadata. | `NoahGetPaymentMethodsData`: | Property | Type | Description | | ---------------- | --------------------- | -------------------------------------------------- | | `paymentMethods` | `[NoahPaymentMethod]` | The customer's stored payment methods. | | `pageToken` | `String?` | Cursor for the next page, when more results exist. | `NoahPaymentMethod`: | Property | Type | Description | | ----------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `id` | `String` | Payment method identifier. | | `paymentMethodCategory` | `String` | Broad grouping such as `Bank`, `Card`, or `Identifier`. | | `country` | `String` | ISO country code. | | `displayDetails` | `NoahPaymentMethodDisplayDetails` | Display fields: `type`, plus optional `accountNumber`, `bankCode`, `bankAddress`, `last4`, `scheme`, `identifierType`, `identifier`. | | `customerId` | `String?` | Owning Noah customer id. | | `capabilities` | `NoahPaymentMethodCapabilities?` | Flags — `payoutFrom`, `payinTo`, `payoutTo`, all `Bool`. | | `accountHolderDetails` | `NoahAccountHolderDetails?` | Holder's structured `name` (`firstName`, `lastName`, optional `middleName`). | | `issuerDetails` | `NoahIssuerDetails?` | Issuer's optional `name`. | ## Example ```swift theme={null} import PortalSwift do { let response = try await portal.ramps.noah.getPaymentMethods( request: NoahGetPaymentMethodsRequest( pageSize: 10, capability: .payoutTo ) ) for method in response.data.paymentMethods { print(method.id, method.paymentMethodCategory, method.displayDetails.last4 ?? "") } if let next = response.data.pageToken { print("Next page token: \(next)") } } catch { print("getPaymentMethods failed: \(error)") } ``` Using the no-argument overload: ```swift theme={null} let response = try await portal.ramps.noah.getPaymentMethods() print(response.data.paymentMethods.count) ``` ## Errors | Error | Description | | ----------------------------------------------- | --------------------------------------------------------- | | `PortalRequestsError.unauthorized` | Authentication failed (401). | | `PortalRequestsError.clientError` | Client error (4xx). An invalid `pageSize` or `pageToken`. | | `PortalRequestsError.internalServerError` | Server error (5xx). | | `PortalRequestsError.couldNotParseHttpResponse` | The response could not be parsed. | | `URLError.badURL` | The request URL could not be built. | ## Related * [Noah virtual accounts and payouts](../guide/noah) * [getPayoutChannels](./noahgetpayoutchannels) * [Noah payouts workflow](/integrations/On-Off-Ramp/noah-payouts) # getPayoutChannelForm Source: https://docs.portalhq.io/sdks/ios/reference/noahgetpayoutchannelform Fetches the dynamic form schema for a Noah payout channel. ## Function Signature ```swift theme={null} public func getPayoutChannelForm(channelId: String) async throws -> NoahGetPayoutChannelFormResponse ``` ## Description Loads the dynamic form schema for a payout channel so you can collect the recipient fields that channel requires before requesting a quote. The `channelId` is percent-encoded as a single path segment, so identifiers containing reserved characters are handled safely. Channels returned by [`getPayoutChannels`](./noahgetpayoutchannels) may already include an inline `formSchema`. When they do, use it directly and skip this call. ## Parameters | Parameter | Type | Required | Description | | ----------- | -------- | -------- | -------------------------------------------- | | `channelId` | `String` | Yes | Channel identifier from `getPayoutChannels`. | ## Returns **`NoahGetPayoutChannelFormResponse`** — an envelope containing: | Property | Type | Description | | ---------- | ------------------------------ | --------------------------- | | `data` | `NoahGetPayoutChannelFormData` | The response payload. | | `metadata` | `NoahResponseMetadata?` | Freeform response metadata. | `NoahGetPayoutChannelFormData`: | Property | Type | Description | | -------------- | ----------------------- | --------------------------------------------- | | `formSchema` | `[String: AnyCodable]?` | JSON Schema describing the fields to collect. | | `formMetadata` | `NoahFormMetadata?` | Schema metadata — `contentHash: String`. | ## Example ```swift theme={null} import PortalSwift do { let response = try await portal.ramps.noah.getPayoutChannelForm(channelId: "ch-1") guard let schema = response.data.formSchema else { print("Channel requires no recipient form") return } // Render form fields from `schema` per Noah's JSON Schema print("Schema keys: \(schema.keys.sorted())") print("Content hash: \(response.data.formMetadata?.contentHash ?? "none")") } catch { print("getPayoutChannelForm failed: \(error)") } ``` ## Errors | Error | Description | | ----------------------------------------------- | -------------------------------------------------- | | `PortalApiError.unableToEncodeData` | The `channelId` could not be percent-encoded. | | `PortalRequestsError.unauthorized` | Authentication failed (401). | | `PortalRequestsError.clientError` | Client error (4xx). The channel id does not exist. | | `PortalRequestsError.internalServerError` | Server error (5xx). | | `PortalRequestsError.couldNotParseHttpResponse` | The response could not be parsed. | | `URLError.badURL` | The request URL could not be built. | ## Related * [Noah virtual accounts and payouts](../guide/noah) * [getPayoutChannels](./noahgetpayoutchannels) * [getPayoutQuote](./noahgetpayoutquote) * [Noah payouts workflow](/integrations/On-Off-Ramp/noah-payouts) # getPayoutChannels Source: https://docs.portalhq.io/sdks/ios/reference/noahgetpayoutchannels Lists Noah payout channels matching the supplied filters. ## Function Signature ```swift theme={null} public func getPayoutChannels(request: NoahGetPayoutChannelsRequest) async throws -> NoahGetPayoutChannelsResponse ``` ## Description Returns the payout rails available for a given crypto asset. Only `cryptoCurrency` is required; `country` and `fiatCurrency` narrow the results, and `fiatAmount` refines channel availability and pre-calculated fees. Results are paginated. Pass the `pageToken` from a previous response to fetch the next page. ## Parameters | Parameter | Type | Required | Description | | ------------------------- | --------- | -------- | -------------------------------------------------------------- | | `request.cryptoCurrency` | `String` | Yes | Crypto asset code for the payout leg. | | `request.country` | `String?` | No | ISO country code, for example `US`. | | `request.fiatCurrency` | `String?` | No | Fiat currency for the payout. | | `request.fiatAmount` | `String?` | No | Amount string used for filtering and pre-calculated fees. | | `request.paymentMethodId` | `String?` | No | Filter to channels compatible with a saved payment method. | | `request.pageSize` | `Int?` | No | Page size between 1 and 100, validated server-side. | | `request.pageToken` | `String?` | No | Pagination cursor from a previous response's `data.pageToken`. | ## Returns **`NoahGetPayoutChannelsResponse`** — an envelope containing: | Property | Type | Description | | ---------- | --------------------------- | --------------------------- | | `data` | `NoahGetPayoutChannelsData` | The response payload. | | `metadata` | `NoahResponseMetadata?` | Freeform response metadata. | `NoahGetPayoutChannelsData`: | Property | Type | Description | | ----------- | --------------- | -------------------------------------------------- | | `items` | `[NoahChannel]` | Matching payout channels. | | `pageToken` | `String?` | Cursor for the next page, when more results exist. | `NoahChannel`: | Property | Type | Description | | ----------------------- | ------------------------------------ | ----------------------------------------------------------------------- | | `id` | `String` | Channel identifier, used as `channelId` in later calls. | | `paymentMethodCategory` | `String` | Broad grouping such as `Bank`, `Card`, or `Identifier`. | | `paymentMethodType` | `String` | Payment rail type, for example `BankSepa` or `IdentifierPix`. | | `fiatCurrency` | `String` | Fiat currency code. | | `country` | `String` | ISO country code. | | `limits` | `NoahChannelLimits` | `minLimit: String` and optional `maxLimit: String?`. | | `rate` | `String` | Exchange rate as a string. | | `processingSeconds` | `Int` | Estimated settlement time in seconds. | | `calculated` | `NoahChannelCalculated?` | Pre-calculated values — `totalFee: String`. | | `paymentMethods` | `[NoahChannelPaymentMethodDisplay]?` | Recent payment methods, only populated when a customer id was supplied. | | `processingTier` | `String?` | Settlement speed tier such as `Standard` or `Priority`. | | `formSchema` | `[String: AnyCodable]?` | Inline JSON Schema for the channel's payout form, when available. | | `formMetadata` | `NoahFormMetadata?` | Schema metadata — `contentHash: String`. | | `issuer` | `String?` | Issuer identifier. | When a channel already carries a `formSchema`, use it directly instead of calling [`getPayoutChannelForm`](./noahgetpayoutchannelform) — it saves a round-trip. ## Example ```swift theme={null} import PortalSwift do { let response = try await portal.ramps.noah.getPayoutChannels( request: NoahGetPayoutChannelsRequest( cryptoCurrency: "USDC_TEST", country: "US", fiatCurrency: "USD", fiatAmount: "10", pageSize: 10 ) ) for channel in response.data.items { print(channel.id, channel.paymentMethodType, channel.rate, channel.limits.minLimit) } if let next = response.data.pageToken { print("Next page token: \(next)") } } catch { print("getPayoutChannels failed: \(error)") } ``` ## Errors | Error | Description | | ----------------------------------------------- | -------------------------------------------------------------------------- | | `PortalRequestsError.unauthorized` | Authentication failed (401). | | `PortalRequestsError.clientError` | Client error (4xx). An invalid `pageSize` or unsupported `cryptoCurrency`. | | `PortalRequestsError.internalServerError` | Server error (5xx). | | `PortalRequestsError.couldNotParseHttpResponse` | The response could not be parsed. | | `URLError.badURL` | The request URL could not be built. | ## Related * [Noah virtual accounts and payouts](../guide/noah) * [getPayoutChannelForm](./noahgetpayoutchannelform) * [getPayoutQuote](./noahgetpayoutquote) * [Noah payouts workflow](/integrations/On-Off-Ramp/noah-payouts) # getPayoutCountries Source: https://docs.portalhq.io/sdks/ios/reference/noahgetpayoutcountries Lists the countries and fiat currencies supported for Noah payouts. ## Function Signature ```swift theme={null} public func getPayoutCountries() async throws -> NoahGetPayoutCountriesResponse ``` ## Description Lists the countries available for fiat payouts, keyed by country code, with the fiat currency codes supported in each. Use this to populate a country picker before calling [`getPayoutChannels`](./noahgetpayoutchannels). ## Parameters This method takes no parameters. ## Returns **`NoahGetPayoutCountriesResponse`** — an envelope containing: | Property | Type | Description | | ---------- | ---------------------------- | --------------------------- | | `data` | `NoahGetPayoutCountriesData` | The response payload. | | `metadata` | `NoahResponseMetadata?` | Freeform response metadata. | `NoahGetPayoutCountriesData`: | Property | Type | Description | | ----------- | -------------------- | --------------------------------------------------------------- | | `countries` | `[String: [String]]` | Country code mapped to the fiat currency codes supported there. | ## Example ```swift theme={null} import PortalSwift do { let response = try await portal.ramps.noah.getPayoutCountries() for (country, currencies) in response.data.countries.sorted(by: { $0.key < $1.key }) { print("\(country): \(currencies.joined(separator: ", "))") } } catch { print("getPayoutCountries failed: \(error)") } ``` ## Errors | Error | Description | | ----------------------------------------------- | ----------------------------------- | | `PortalRequestsError.unauthorized` | Authentication failed (401). | | `PortalRequestsError.clientError` | Client error (4xx). | | `PortalRequestsError.internalServerError` | Server error (5xx). | | `PortalRequestsError.couldNotParseHttpResponse` | The response could not be parsed. | | `URLError.badURL` | The request URL could not be built. | ## Related * [Noah virtual accounts and payouts](../guide/noah) * [getPayoutChannels](./noahgetpayoutchannels) * [Noah payouts workflow](/integrations/On-Off-Ramp/noah-payouts) # getPayoutQuote Source: https://docs.portalhq.io/sdks/ios/reference/noahgetpayoutquote Requests a Noah payout quote for a channel and form responses. ## Function Signature `NoahGetPayoutQuoteRequest` has two initializers. Choosing one selects whether the quote is denominated in fiat or crypto — passing both amounts, or neither, will not compile. ```swift theme={null} public func getPayoutQuote(request: NoahGetPayoutQuoteRequest) async throws -> NoahGetPayoutQuoteResponse // Quote a fiat amount to receive public init( channelId: String, cryptoCurrency: String, fiatAmount: String, quoted: Bool? = nil, form: [String: AnyCodable]? = nil, fiatCurrency: String? = nil, paymentMethodId: String? = nil, formSessionId: String? = nil, businessFee: NoahBusinessFee? = nil ) // Quote a crypto amount to sell public init( channelId: String, cryptoCurrency: String, cryptoAmount: String, quoted: Bool? = nil, form: [String: AnyCodable]? = nil, fiatCurrency: String? = nil, paymentMethodId: String? = nil, formSessionId: String? = nil, businessFee: NoahBusinessFee? = nil ) ``` ## Description Requests fees and crypto amount estimates for a payout on a given channel. Include `form` when the channel requires recipient data — fetch its schema with [`getPayoutChannelForm`](./noahgetpayoutchannelform) or read the inline `formSchema` on the channel. The returned `payoutId` and `formSessionId` feed into [`initiatePayout`](./noahinitiatepayout). ## Parameters | Parameter | Type | Required | Description | | ------------------------- | ----------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | `request.channelId` | `String` | Yes | Payout channel id from `getPayoutChannels`. | | `request.cryptoCurrency` | `String` | Yes | Crypto asset for the quote. | | `request.fiatAmount` | `String` | One of `fiatAmount` or `cryptoAmount` | Fiat amount to receive, as a string. | | `request.cryptoAmount` | `String` | One of `fiatAmount` or `cryptoAmount` | Crypto amount to sell, as a string. | | `request.quoted` | `Bool?` | No | Request a signed, rate-locked quote. Required if you intend to submit the payout with a `.quoted` trigger. | | `request.form` | `[String: AnyCodable]?` | No | Recipient fields from the channel form. | | `request.fiatCurrency` | `String?` | No | Fiat currency override when needed. | | `request.paymentMethodId` | `String?` | No | Saved payment method to attach to the quote. | | `request.formSessionId` | `String?` | No | Existing form session to continue, for multi-step forms. | | `request.businessFee` | `NoahBusinessFee?` | No | Business fee override. Forwarded to Noah as `BusinessFee`. | ## Returns **`NoahGetPayoutQuoteResponse`** — an envelope containing: | Property | Type | Description | | ---------- | ------------------------ | --------------------------- | | `data` | `NoahGetPayoutQuoteData` | The response payload. | | `metadata` | `NoahResponseMetadata?` | Freeform response metadata. | `NoahGetPayoutQuoteData`: | Property | Type | Description | | ------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------ | | `payoutId` | `String` | Identifier to pass to `initiatePayout`. | | `totalFee` | `String` | Total fee for the payout. | | `cryptoAmountEstimate` | `String` | Estimated crypto amount required. | | `cryptoAuthorizedAmount` | `String` | Crypto amount the client is authorized to send for this quote. | | `formSessionId` | `String` | Form session identifier for multi-step flows. | | `cryptoCurrency` | `String?` | Crypto currency the quote was priced in. | | `fiatCurrency` | `String?` | Fiat currency the quote was priced in. | | `fiatAmount` | `String?` | Resolved fiat amount for the quote. | | `rate` | `String?` | Exchange rate applied to the quote. | | `breakdown` | `[NoahTransactionBreakdownItem]?` | Fee line items. Each has `type` (`ChannelFee`, `BusinessFee`, or `Remaining`) and `amount`. | | `quote` | `NoahSellQuote?` | Signed quote — `signedQuote: String` and `expiry: String`. Present when `quoted` was `true`. | | `nextStep` | `NoahFormNextStep?` | Next step in a multi-step form — `stepId`, `stepType` (`.ack` or `.dataEntry`), and optional `schema`. | ## Example ```swift theme={null} import PortalSwift do { let quote = try await portal.ramps.noah.getPayoutQuote( request: NoahGetPayoutQuoteRequest( channelId: "ch-1", cryptoCurrency: "USDC_TEST", fiatAmount: "10" ) ) print("Payout: \(quote.data.payoutId)") print("Send: \(quote.data.cryptoAuthorizedAmount)") print("Fee: \(quote.data.totalFee)") for item in quote.data.breakdown ?? [] { print(" \(item.type): \(item.amount)") } } catch { print("getPayoutQuote failed: \(error)") } ``` Request a rate-locked quote when the payout will use a `.quoted` trigger: ```swift theme={null} let quote = try await portal.ramps.noah.getPayoutQuote( request: NoahGetPayoutQuoteRequest( channelId: "ch-1", cryptoCurrency: "USDC_TEST", cryptoAmount: "10.5", quoted: true ) ) let signedQuote = quote.data.quote?.signedQuote ``` ## Errors | Error | Description | | ----------------------------------------------- | -------------------------------------------------------------------------------------------- | | `PortalRequestsError.unauthorized` | Authentication failed (401). | | `PortalRequestsError.clientError` | Client error (4xx). Missing required form fields, or an amount outside the channel's limits. | | `PortalRequestsError.internalServerError` | Server error (5xx). | | `PortalRequestsError.couldNotParseHttpResponse` | The response could not be parsed. | | `URLError.badURL` | The request URL could not be built. | ## Related * [Noah virtual accounts and payouts](../guide/noah) * [getPayoutChannelForm](./noahgetpayoutchannelform) * [initiatePayout](./noahinitiatepayout) * [Noah payouts workflow](/integrations/On-Off-Ramp/noah-payouts) # initiateKyc Source: https://docs.portalhq.io/sdks/ios/reference/noahinitiatekyc Starts hosted Noah KYC onboarding and returns a hosted URL. ## Function Signature ```swift theme={null} public func initiateKyc(request: NoahInitiateKycRequest) async throws -> NoahInitiateKycResponse ``` ## Description Starts a Noah KYC session for the current customer and returns a hosted onboarding URL. Open the URL in the system browser after validating its scheme and hostname. This endpoint is **idempotent**. If a Noah customer record already exists for the client, the previously stored `hostedUrl` is returned regardless of KYC status (`Pending`, `Submitted`, `Approved`, `Declined`). Calling it again does not restart onboarding and does not mint a fresh URL. The KYC outcome is delivered asynchronously through Noah `Customer` webhooks, not through this response. ## Parameters | Parameter | Type | Required | Description | | ---------------------- | ----------------------- | -------- | --------------------------------------------------------------------------------------------- | | `request.returnUrl` | `String` | Yes | HTTPS URL where Noah returns the user after onboarding. Custom app schemes are not supported. | | `request.fiatOptions` | `[NoahFiatOption]?` | No | Fiat currencies to present during onboarding. | | `request.customerType` | `NoahCustomerType?` | No | Onboarding flow variant — `.individual` or `.business`. | | `request.metadata` | `[String: AnyCodable]?` | No | Opaque metadata forwarded per API rules. | | `request.form` | `[String: AnyCodable]?` | No | Optional prefill payload for hosted forms. | ## Returns **`NoahInitiateKycResponse`** — an envelope containing: | Property | Type | Description | | ---------- | ----------------------- | ---------------------------------------------------- | | `data` | `NoahInitiateKycData` | The response payload. | | `metadata` | `NoahResponseMetadata?` | Freeform response metadata (`[String: AnyCodable]`). | `NoahInitiateKycData`: | Property | Type | Description | | ----------- | -------- | --------------------------------------------------------- | | `hostedUrl` | `String` | Hosted Noah onboarding URL to open in the system browser. | ## Example ```swift theme={null} import PortalSwift import UIKit let allowedHosts: Set = [ "checkout.noah.com", "checkout.sandbox.noah.com", "staging-checkout.noah.com", ] do { let response = try await portal.ramps.noah.initiateKyc( request: NoahInitiateKycRequest( returnUrl: "https://yourapp.example/noah/return", fiatOptions: [NoahFiatOption(fiatCurrencyCode: "USD")], customerType: .individual ) ) if let url = URL(string: response.data.hostedUrl), url.scheme == "https", let host = url.host, allowedHosts.contains(host) { await UIApplication.shared.open(url) } else { // Handle this however your app prefers — surface it, log it, or fail the flow. print("Noah returned an unexpected KYC URL: \(response.data.hostedUrl)") } } catch { print("initiateKyc failed: \(error)") } ``` ## Errors | Error | Description | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `PortalRequestsError.unauthorized` | Authentication failed (401). Verify your Portal client API key and that Noah is enabled for your environment. | | `PortalRequestsError.clientError` | Client error (4xx). The request was invalid or malformed. | | `PortalRequestsError.internalServerError` | Server error (5xx). | | `PortalRequestsError.couldNotParseHttpResponse` | The response could not be parsed. | | `URLError.badURL` | The request URL could not be built. | ## Related * [Noah virtual accounts and payouts](../guide/noah) * [Noah KYC workflow](/integrations/On-Off-Ramp/noah-kyc) * [Noah webhooks](/integrations/On-Off-Ramp/noah-webhooks) # initiatePayin Source: https://docs.portalhq.io/sdks/ios/reference/noahinitiatepayin Creates a Noah payin and returns bank deposit instructions. ## Function Signature ```swift theme={null} public func initiatePayin(request: NoahInitiatePayinRequest) async throws -> NoahInitiatePayinResponse ``` ## Description Initiates a Noah payin (on-ramp) and returns a `payinId` plus the bank deposit instructions the customer should use to fund it. Once the fiat deposit settles, Noah sends crypto to `destinationAddress` on the specified network. Payin lifecycle updates arrive asynchronously through Noah `FiatDeposit` and `Transaction` webhooks — do not poll this response. ## Parameters | Parameter | Type | Required | Description | | ---------------------------- | ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------------- | | `request.fiatCurrency` | `String` | Yes | Fiat currency code, for example `USD`. | | `request.cryptoCurrency` | `String` | Yes | Noah crypto asset code, for example `USDC_TEST` in sandbox. | | `request.network` | `String` | Yes | CAIP-2 chain identifier. Use a `NoahNetwork` constant. | | `request.destinationAddress` | `String` | Yes | Address that receives crypto after settlement. | | `request.businessFees` | `[String: NoahBusinessFee]?` | No | Per-payment-method business fee overrides, keyed by payment method type. Forwarded to Noah as `BusinessFees`. | ## Returns **`NoahInitiatePayinResponse`** — an envelope containing: | Property | Type | Description | | ---------- | ----------------------- | --------------------------- | | `data` | `NoahInitiatePayinData` | The response payload. | | `metadata` | `NoahResponseMetadata?` | Freeform response metadata. | `NoahInitiatePayinData`: | Property | Type | Description | | ------------- | ----------------- | -------------------------------------- | | `payinId` | `String` | Identifier for the created payin. | | `bankDetails` | `NoahBankDetails` | Deposit instructions for the customer. | `NoahBankDetails`: | Property | Type | Description | | ----------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------- | | `paymentMethodId` | `String` | Payment method identifier. | | `paymentMethodType` | `String` | Payment rail type, for example `BankSepa` or `IdentifierPix`. | | `accountNumber` | `String` | Bank account number to deposit into. | | `cryptoCurrency` | `String` | Crypto currency for this payin. | | `network` | `String` | Network identifier. | | `fee` | `NoahFeeDetails` | Fee breakdown: `fiatCurrencyCode`, `totalFeePct`, `totalFeeBase`, `totalFeeMin`. | | `accountHolderName` | `String?` | Account holder name. | | `bankCode` | `String?` | Bank routing or sort code. | | `bankName` | `String?` | Bank name. | | `bankAddress` | `NoahBankAddress?` | Bank address: `street`, `street2`, `city`, `postCode`, `state`, `country` — all optional. | | `reference` | `String?` | Payment reference the customer must include. | | `relatedPaymentMethods` | `[NoahBankToAddressRelatedPaymentMethod]?` | Related payment methods for this destination. | ## Example ```swift theme={null} import PortalSwift do { let response = try await portal.ramps.noah.initiatePayin( request: NoahInitiatePayinRequest( fiatCurrency: "USD", cryptoCurrency: "USDC_TEST", network: NoahNetwork.solanaDevnet, destinationAddress: "SoLAddr1111111111111111111111111111111111111" ) ) print("Payin: \(response.data.payinId)") print("Deposit to: \(response.data.bankDetails.accountNumber)") print("Reference: \(response.data.bankDetails.reference ?? "none")") } catch { print("initiatePayin failed: \(error)") } ``` ## Errors | Error | Description | | ----------------------------------------------- | ------------------------------------------------------------------------------------------ | | `PortalRequestsError.unauthorized` | Authentication failed (401). | | `PortalRequestsError.clientError` | Client error (4xx). An unsupported `network` value, or the customer has not completed KYC. | | `PortalRequestsError.internalServerError` | Server error (5xx). | | `PortalRequestsError.couldNotParseHttpResponse` | The response could not be parsed. | | `URLError.badURL` | The request URL could not be built. | ## Related * [Noah virtual accounts and payouts](../guide/noah) * [Noah payins workflow](/integrations/On-Off-Ramp/noah-payins) * [Noah webhooks](/integrations/On-Off-Ramp/noah-webhooks) # initiatePayout Source: https://docs.portalhq.io/sdks/ios/reference/noahinitiatepayout Initiates a Noah payout from a previously quoted payout. ## Function Signature ```swift theme={null} public func initiatePayout(request: NoahInitiatePayoutRequest) async throws -> NoahInitiatePayoutResponse ``` ## Description Executes a payout that was previously quoted with [`getPayoutQuote`](./noahgetpayoutquote). Returns the destination address and the on-chain deposit conditions your transfer must satisfy. The payout must be authorized by a trigger. Either supply one explicitly through `trigger`, or rely on a saved payment method attached to the quote via `paymentMethodId` — when `trigger` is omitted and the saved method is a single on-chain deposit source, a default trigger is synthesised from the `sourceAddress`, `expiry`, and `nonce` you passed. Completion and failures are reported asynchronously through Noah `Transaction` webhooks. ## Parameters | Parameter | Type | Required | Description | | ----------------------- | ---------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ | | `request.payoutId` | `String` | Yes | Identifier from `getPayoutQuote`. | | `request.sourceAddress` | `String` | Yes | Address funding the crypto leg. | | `request.expiry` | `String` | Yes | ISO-8601 expiry for the deposit authorization. | | `request.nonce` | `String` | Yes | Stable nonce for this payout attempt. **Reuse it on retry** so repeated calls stay idempotent. Must be 36 characters or fewer. | | `request.network` | `String` | Yes | CAIP-2 network for the deposit leg. Use a `NoahNetwork` constant. | | `request.trigger` | `NoahOnchainDepositSourceTrigger?` | No | Explicit on-chain deposit trigger. See below. | | `request.businessFee` | `NoahBusinessFee?` | No | Business fee override. Forwarded to Noah as `BusinessFee`. | ### Deposit source triggers `NoahOnchainDepositSourceTrigger` is an enum with three cases. The wire format is discriminated by a `Type` field, so the enum encodes its wrapped value transparently — no extra nesting is added to the JSON. Each trigger input defaults its `type` property to the correct discriminator. | Case | Payload | Use it for | | ------------ | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `.single` | `NoahSingleOnchainDepositSourceTriggerInput` | One payout matching specific amount conditions on a network. | | `.permanent` | `NoahPermanentOnchainDepositSourceTriggerInput` | A source that stays active across multiple deposits until expiry. Set `networkAgnostic` to match any supported network. | | `.quoted` | `NoahQuotedOnchainDepositSourceTriggerInput` | A rate-locked payout. Requires the `signedQuote` from a `getPayoutQuote` call made with `quoted: true`. | Trigger inputs serialize their fields in PascalCase (`Type`, `Conditions`, `SourceAddress`, `Expiry`, `Nonce`) to match the Noah API wire format. ## Returns **`NoahInitiatePayoutResponse`** — an envelope containing: | Property | Type | Description | | ---------- | ------------------------ | --------------------------- | | `data` | `NoahInitiatePayoutData` | The response payload. | | `metadata` | `NoahResponseMetadata?` | Freeform response metadata. | `NoahInitiatePayoutData`: | Property | Type | Description | | -------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `destinationAddress` | `String?` | Address to send the crypto leg to. Derived from `conditions[0].destinationAddress`; `nil` when that shape is missing. | | `conditions` | `[NoahDepositSourceTriggerCondition]?` | Conditions the on-chain transfer must satisfy. Each has `amountConditions`, `cryptoCurrency`, `network`, and `destinationAddress`. | | `ruleId` | `String?` | Identifier of the Noah rule created for permanent and quoted triggers. | ## Example ```swift theme={null} import PortalSwift let formatter = ISO8601DateFormatter() let expiry = formatter.string(from: Date().addingTimeInterval(24 * 60 * 60)) // Use a stable nonce per payout attempt and reuse it on retries let nonce = String(UUID().uuidString.prefix(36)) do { let response = try await portal.ramps.noah.initiatePayout( request: NoahInitiatePayoutRequest( payoutId: quote.data.payoutId, sourceAddress: "SoLAddr1111111111111111111111111111111111111", expiry: expiry, nonce: nonce, network: NoahNetwork.solanaDevnet ) ) if let destination = response.data.destinationAddress { print("Send funds to: \(destination)") } else { // Handle this however your app prefers — surface it, log it, or fail the flow. print("Noah did not return a destination address for this payout") } } catch { print("initiatePayout failed: \(error)") } ``` With an explicit `.single` trigger: ```swift theme={null} let trigger = NoahOnchainDepositSourceTrigger.single( NoahSingleOnchainDepositSourceTriggerInput( conditions: [ NoahSingleOnchainDepositSourceTriggerCondition( amountConditions: [ NoahSingleOnchainDepositSourceTriggerAmountCondition( comparisonOperator: .eq, value: quote.data.cryptoAuthorizedAmount ) ], network: NoahNetwork.solanaDevnet ) ], sourceAddress: "SoLAddr1111111111111111111111111111111111111", expiry: expiry, nonce: nonce ) ) let response = try await portal.ramps.noah.initiatePayout( request: NoahInitiatePayoutRequest( payoutId: quote.data.payoutId, sourceAddress: "SoLAddr1111111111111111111111111111111111111", expiry: expiry, nonce: nonce, network: NoahNetwork.solanaDevnet, trigger: trigger ) ) ``` ## Errors | Error | Description | | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `PortalRequestsError.unauthorized` | Authentication failed (401). | | `PortalRequestsError.clientError` | Client error (4xx). No trigger supplied and no saved payment method on the quote, an expired quote, or an unsupported `network`. | | `PortalRequestsError.internalServerError` | Server error (5xx). | | `PortalRequestsError.couldNotParseHttpResponse` | The response could not be parsed. | | `URLError.badURL` | The request URL could not be built. | ## Related * [Noah virtual accounts and payouts](../guide/noah) * [getPayoutQuote](./noahgetpayoutquote) * [Send tokens](../guide/send-tokens) — satisfy the returned deposit conditions with `portal.sendAsset(...)` * [Noah payouts workflow](/integrations/On-Off-Ramp/noah-payouts) * [Noah webhooks](/integrations/On-Off-Ramp/noah-webhooks) # simulatePayin Source: https://docs.portalhq.io/sdks/ios/reference/noahsimulatepayin Simulates a sandbox-only Noah fiat deposit for testing payin flows. ## Function Signature ```swift theme={null} public func simulatePayin(request: NoahSimulatePayinRequest) async throws -> NoahSimulatePayinResponse ``` ## Description Simulates a fiat deposit against a Noah payment method so you can exercise the payin flow end to end without moving real money. This endpoint is **sandbox-only**. Calling it against a production environment fails with a `400` client error — `Simulate fiat deposit is only available in sandbox environments`. Guard the call behind your own environment check rather than shipping it in a production code path. ## Parameters | Parameter | Type | Required | Description | | ------------------------- | -------- | -------- | ------------------------------------ | | `request.paymentMethodId` | `String` | Yes | Payment method identifier from Noah. | | `request.fiatAmount` | `String` | Yes | Fiat amount as a decimal string. | | `request.fiatCurrency` | `String` | Yes | Fiat currency code. | ## Returns **`NoahSimulatePayinResponse`** — an envelope containing: | Property | Type | Description | | ---------- | ----------------------- | --------------------------- | | `data` | `NoahSimulatePayinData` | The response payload. | | `metadata` | `NoahResponseMetadata?` | Freeform response metadata. | `NoahSimulatePayinData`: | Property | Type | Description | | --------------- | --------- | ----------------------------------------------------------- | | `fiatDepositId` | `String` | Identifier of the simulated fiat deposit. | | `reference` | `String?` | Reference for the simulated deposit, when returned by Noah. | ## Example ```swift theme={null} import PortalSwift #if DEBUG do { let response = try await portal.ramps.noah.simulatePayin( request: NoahSimulatePayinRequest( paymentMethodId: "pm-1", fiatAmount: "10", fiatCurrency: "USD" ) ) print("Simulated deposit: \(response.data.fiatDepositId)") } catch { print("simulatePayin failed: \(error)") } #endif ``` ## Errors | Error | Description | | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PortalRequestsError.clientError` | Client error (4xx). A `400` is returned when the call is made against a production environment, or when `paymentMethodId`, `fiatAmount`, or `fiatCurrency` is invalid. | | `PortalRequestsError.unauthorized` | Authentication failed (401). | | `PortalRequestsError.internalServerError` | Server error (5xx). | | `PortalRequestsError.couldNotParseHttpResponse` | The response could not be parsed. | | `URLError.badURL` | The request URL could not be built. | ## Related * [Noah virtual accounts and payouts](../guide/noah) * [initiatePayin](./noahinitiatepayin) * [Noah payins workflow](/integrations/On-Off-Ramp/noah-payins) # recoverWallet Source: https://docs.portalhq.io/sdks/ios/reference/recoverwallet Recovers a wallet using a specified backup method. **Function Signature** ```swift theme={null} public func recoverWallet( _ method: BackupMethods, withCipherText: String? = nil, usingProgressCallback: ((MpcStatus) -> Void)? = nil ) async throws -> PortalRecoverWalletResponse ``` **Parameters** * `method`: The backup method to use for recovery. Supported methods: * `.GoogleDrive`: Recover from Google Drive backup * `.iCloud`: Recover from iCloud backup * `.Password`: Recover using password * `.Passkey`: Recover using passkey * `.Firebase`: Recover using Firebase Auth Backup * `.local`: Recover from local backup * `withCipherText`: Optional cipher text from a previous backup. Required for the [Self-Managed Backups](../../../resources/self-managed-backups). * `usingProgressCallback`: Optional callback to track the recovery progress. Receives an `MpcStatus` object containing: * `status`: Current operation being performed: * `.readingShare`: Reading the stored share * `.decryptingShare`: Decrypting the share * `.parsingShare`: Parsing the share data * `.generatingShare`: Generating the share data * `.storingShare`: Saving the recovered share * `.done`: Process completed * `done`: Boolean indicating whether the operation is complete **Returns** `PortalRecoverWalletResponse` containing: * `ethereum`: The recovered Ethereum address * `solana`: The recovered Solana address, if available **Throws** * `PortalClassError.cannotRecoverWallet` if the Ethereum address cannot be recovered * Various backup method-specific errors if recovery fails **Example Usage** ```swift theme={null} do { let recoveredWallet = try await portal.recoverWallet( .iCloud, withCipherText: backupCipherText ) { status in switch status.status { case .readingShare: print("Reading backup share...") case .decryptingShare: print("Decrypting share...") case .parsingShare: print("Parsing share data...") case .generatingShare: print("Regenerating wallet...") case .storingShare: print("Saving recovered wallet...") case .done: print("Recovery completed!") default: break } } print("Recovered Ethereum address: \(recoveredWallet.ethereum)") if let solanaAddress = recoveredWallet.solana { print("Recovered Solana address: \(solanaAddress)") } } catch { print("Error recovering wallet: \(error)") } ``` # registerBackupMethod Source: https://docs.portalhq.io/sdks/ios/reference/registerbackupmethod Registers a storage implementation for a specific backup method. You can provide the backup methods when initializing the Portal main object. **Function Signature** ```swift theme={null} public func registerBackupMethod( _ method: BackupMethods, withStorage: PortalStorage ) ``` **Parameters** * `method`: The backup method to register. Supported methods: * `.GoogleDrive`: Google Drive storage * `.iCloud`: iCloud storage * `.Password`: Password-protected storage * `.Passkey`: Passkey authentication storage * `.Firebase`: Firebase Auth Backup storage * `.local`: Local storage * `.Unknown`: Can be used for custom storage * `withStorage`: A custom implementation of `PortalStorage` protocol that handles the storage operations for the specified backup method **Notes** * Each backup method must have a registered storage implementation before it can be used for backup or recovery operations * This method should be called before attempting any backup or recovery operations with the corresponding method **Example Usage** ```swift theme={null} // Example 1: Registering a custom password storage class MyCustomPasswordStorage: PortalStorage { // Custom implementation of PortalStorage protocol } do { let customStorage = MyCustomPasswordStorage() portal.registerBackupMethod(.Password, withStorage: customStorage) } catch { print("Error registering backup method: \(error)") } // Example 2: Registering iCloud storage do { let iCloudStorage = ICloudStorage(mobile: binaryWrapper) portal.registerBackupMethod(.iCloud, withStorage: iCloudStorage) } catch { print("Error registering iCloud storage: \(error)") } // Example 3: Registering Firebase Auth Backup storage import FirebaseAuth portal.registerBackupMethod(.Firebase, withStorage: FirebaseStorage( getToken: { guard let user = Auth.auth().currentUser else { return nil } return try await user.getIDToken(forcingRefresh: true) } )) ``` **Custom Storage Implementation** When implementing a custom storage solution, your class must conform to the `PortalStorage` protocol: ```swift theme={null} protocol PortalStorage { var api: PortalApiProtocol? { get set } var encryption: PortalEncryptionProtocol { get } func decrypt(_ value: String, withKey: String) async throws -> String func delete() async throws -> Bool func encrypt(_ value: String) async throws -> EncryptData func read() async throws -> String func validateOperations() async throws -> Bool func write(_ value: String) async throws -> Bool } ``` **Related Documentation** For more information about backup methods, see: * [Backup Methods Overview](../guide/back-up-a-wallet) * [Firebase Auth Backup](../guide/back-up-a-wallet#firebase-auth-backup) * [FirebaseStorage reference](./firebasestorage) # sendSol Source: https://docs.portalhq.io/sdks/ios/reference/sendsol Sends SOL tokens to a specified Solana address. This method constructs and sends a Solana transfer transaction. **Function Signature** ```swift theme={null} public func sendSol( _ lamports: UInt64, to: String, withChainId chainId: String ) async throws -> String ``` **Parameters** * `lamports`: Amount of lamports to send (1 SOL = 1,000,000,000 lamports) * `to`: The recipient's Solana address * `withChainId`: The CAIP-2 chain identifier (e.g., "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" for Solana mainnet) **Returns** The transaction hash (signature) as a string **Throws** * `MpcError.addressNotFound` if no Solana address is found for the wallet * `PortalSolError.failedToGetLatestBlockhash` if unable to get the latest blockhash * `PortalSolError.failedToGetTransactionHash` if transaction signing/sending fails * Errors from `SolanaSwift.PublicKey` initialization if addresses are invalid **Example Usage** ```swift theme={null} // Basic SOL transfer do { // Send 1 SOL (1 SOL = 1,000,000,000 lamports) let txHash = try await portal.sendSol( 1_000_000_000, to: "recipient_solana_address", withChainId: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" ) print("Transaction sent: \(txHash)") } catch { print("Error sending SOL: \(error)") } // Example with amount calculation func sendSol(amount: Double, to recipient: String) async throws { // Convert SOL to lamports (1 SOL = 1,000,000,000 lamports) let lamports = UInt64(amount * 1_000_000_000) do { let txHash = try await portal.sendSol( lamports, to: recipient, withChainId: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" ) print("Sent \(amount) SOL") print("Transaction hash: \(txHash)") } catch MpcError.addressNotFound { print("No Solana wallet found. Please create or recover a wallet first.") } catch { print("Transaction failed: \(error)") } } // Complete example with balance check func sendSolWithBalanceCheck( amount: Double, to recipient: String ) async throws { // Get current balance first let assets = try await portal.getAssets("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp") guard let nativeBalance = assets.nativeBalance, let balanceStr = nativeBalance.balance, let balance = Double(balanceStr) else { throw CustomError.balanceCheckFailed } // Check if we have enough balance (including some for fees) let requiredAmount = amount + 0.001 // Add some for fees guard balance >= requiredAmount else { throw CustomError.insufficientBalance( has: balance, needs: requiredAmount ) } // Convert SOL to lamports let lamports = UInt64(amount * 1_000_000_000) // Send transaction let txHash = try await portal.sendSol( lamports, to: recipient, withChainId: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" ) print("Transaction successful!") print("Amount: \(amount) SOL") print("To: \(recipient)") print("Hash: \(txHash)") } // Example usage in a view controller class SendSolViewController: UIViewController { @IBAction func sendButtonTapped() { Task { do { try await sendSolWithBalanceCheck( amount: 0.1, to: "recipient_address" ) await MainActor.run { showSuccess("Transaction sent successfully!") } } catch let error as CustomError { await MainActor.run { handleCustomError(error) } } catch { await MainActor.run { showError("Transaction failed: \(error.localizedDescription)") } } } } } ``` **Implementation Notes** * Run `generate`or `recover` if needed. * Amount must be specified in lamports (1 SOL = 1,000,000,000 lamports) * The transaction is automatically signed and sent to the network * The fee is paid by the sender's address * Consider adding extra amount for transaction fees * The function waits for transaction submission but not confirmation # setGDriveConfiguration Source: https://docs.portalhq.io/sdks/ios/reference/setgdriveconfiguration Configures Google Drive settings for the SDK with a specified backup option. **Function Signature** ```swift theme={null} public func setGDriveConfiguration( clientId: String, backupOption: GDriveBackupOption ) throws ``` **Parameters** * `clientId`: The client ID for the Google Drive integration. Obtained from Google Cloud Console. * `backupOption`: An option from the `GDriveBackupOption` enum that specifies the backup/recover storage type: * `.appDataFolder`: Stores backups in the hidden, app-specific "App Data Folder" in Google Drive. This folder is not visible to the user. * `.appDataFolderWithFallback`: Attempts to store backups and recover using the "App Data Folder". If recovery fails, it automatically falls back to a user-visible Google Drive folder. * `.gdriveFolder(folderName: String)`: Stores backups in a user-visible folder in Google Drive with the specified `folderName`. **Important Notes** * The `appDataFolder` and `appDataFolderWithFallback` options are supported starting from SDK version 4.2.0 * Those options cannot be used with an earlier SDK version, backups stored in the App Data Folder will be lost * Choose the appropriate backup option based on your application's requirements: * Use `appDataFolder` to keep wallet backups hidden from users (recommended) * Use `gdriveFolder` to allow wallet backups to be seen and modified by the user **Example Usage** ```swift theme={null} // Example 1: Using App Data Folder (recommended) do { try portal.setGDriveConfiguration( clientId: "your-google-client-id", backupOption: .appDataFolder ) } catch { print("Error configuring Google Drive with App Data Folder: \(error)") } // Example 2: Using visible folder with custom name do { try portal.setGDriveConfiguration( clientId: "your-google-client-id", backupOption: .gdriveFolder(folderName: "MyAppBackups") ) } catch { print("Error configuring Google Drive with custom folder: \(error)") } // Example 3: Using App Data Folder for backup and recover with fallback to GDrive Folder if recover fails do { try portal.setGDriveConfiguration( clientId: "your-google-client-id", backupOption: .appDataFolderWithFallback ) } catch { print("Error configuring Google Drive with fallback: \(error)") } ``` **Related Documentation** For more information about Google Drive integration, see: * [Configure GDrive storage](../../../resources/backup-options/gdrive) * [Google Drive Backup Method](../guide/back-up-a-wallet) # setLogLevel Source: https://docs.portalhq.io/sdks/ios/reference/setloglevel Changes the SDK log verbosity at runtime without reinitializing Portal. **Function Signature** ```swift theme={null} public func setLogLevel(_ level: PortalLogLevel) ``` **Parameters** * **`level`**: The desired log verbosity. One of the `PortalLogLevel` enum cases: * **`.none`** — No output. This is the default. * **`.error`** — Failures only (failed transactions, network errors, binary crashes). * **`.warn`** — Non-fatal unexpected conditions (deprecated usage, retries, slow responses). * **`.info`** — Operational milestones (signing started, share generated, connection opened). * **`.debug`** — Full internals (request/response payloads, timing, state transitions). Each level includes all levels above it in severity. **Returns** * `Void` **Example Usage** ```swift theme={null} import PortalSwift do { let portal = try Portal( "YOUR_CLIENT_API_KEY", withRpcConfig: ["eip155:1": "https://mainnet.infura.io/v3/YOUR_INFURA_KEY"] ) // Enable debug logging during development portal.setLogLevel(.debug) // Create wallet — all SDK log output is now visible let addresses = try await portal.createWallet() print("Ethereum address: \(addresses.ethereum)") } catch { print("Error: \(error)") } ``` # setPasskeyAuthenticationAnchor Source: https://docs.portalhq.io/sdks/ios/reference/setpasskeyauthenticationanchor Sets the presentation anchor for passkey authentication dialogs. This method is required for configuring where the passkey UI components will be displayed in your application. **Function Signature** ```swift theme={null} @available(iOS 16, *) public func setPasskeyAuthenticationAnchor( _ anchor: ASPresentationAnchor ) throws ``` **Parameters** * `anchor`: The window anchor where passkey authentication UI will be presented. This is typically the main window of your application and should be of type `ASPresentationAnchor` (which UIWindow conforms to). **Platform Availability** * Requires iOS 16.0 or later * Part of the native passkey implementation in iOS **Notes** * Must be called before using passkey backup or recovery methods * Should be used in conjunction with `setPasskeyConfiguration` for complete setup * The anchor is used to present the system's native passkey authentication dialog **Example Usage** ```swift theme={null} // Basic usage in UIKit if #available(iOS 16, *) { do { // Set the authentication anchor using the main window try portal.setPasskeyAuthenticationAnchor(view.window!) } catch { print("Error setting passkey authentication anchor: \(error)") } } // Complete setup in UIViewController class MyViewController: UIViewController { func setupPasskeys() { if #available(iOS 16, *) { do { // Configure passkey settings try portal.setPasskeyConfiguration( relyingParty: "portalhq.io", webAuthnHost: "backup.web.portalhq.io" ) // Set the authentication anchor guard let window = view.window else { print("Error: No window available") return } try portal.setPasskeyAuthenticationAnchor(window) print("Passkey authentication setup complete") } catch { print("Error setting up passkey authentication: \(error)") } } } } // Usage in SwiftUI import SwiftUI struct PasskeyView: View { @Environment(\.window) var window: UIWindow? func setupPasskeyAuth() { if #available(iOS 16, *) { do { guard let window = window else { print("Error: No window available") return } try portal.setPasskeyAuthenticationAnchor(window) } catch { print("Error setting passkey authentication anchor: \(error)") } } } var body: some View { Text("Passkey Setup") .onAppear { setupPasskeyAuth() } } } ``` **Implementation Guide** 1. Ensure you're running on iOS 16 or later 2. Call `setPasskeyConfiguration` first to set up basic passkey settings 3. Obtain a reference to your app's main window or appropriate presentation context 4. Call this method with the window reference 5. Now you can use passkey-based authentication features **UI Considerations** * The passkey dialog will be presented as a system modal over your application * The presentation anchor should be stable and available throughout the authentication process * For best user experience, call this method when your view hierarchy is fully established **Related Documentation** For more information about passkey, see: * [Passkey + Enclave Backup](../guide/back-up-a-wallet) * [Configuring an associated domain](https://developer.apple.com/documentation/xcode/configuring-an-associated-domain#Provide-an-Apple-App-Site-Association-file) * [Supporting associated domains](https://developer.apple.com/documentation/xcode/supporting-associated-domains#Add-the-associated-domain-file-to-your-website) * [Passkey Configuration](./setpasskeyconfiguration) # setPasskeyConfiguration Source: https://docs.portalhq.io/sdks/ios/reference/setpasskeyconfiguration Configures the passkey authentication settings for backup and recovery operations. This method must be called before using passkey backup or recovery methods. **Function Signature** ```swift theme={null} @available(iOS 16, *) public func setPasskeyConfiguration( relyingParty: String, webAuthnHost: String ) throws ``` **Parameters** * `relyingParty`: The relying party identifier for WebAuthn/passkey authentication. This is typically your application's domain name (e.g., "portalhq.io") * `webAuthnHost`: The WebAuthn host that will handle passkey operations. This should match your application's authentication server. **Platform Availability** * Requires iOS 16.0 or later * Uses the native passkey APIs introduced in iOS 16 **Notes** * Must be called before using passkey backup or recovery methods * The relying party should match your application's domain name for security purposes * Should be used in conjunction with `setPasskeyAuthenticationAnchor` for complete setup **Example Usage** ```swift theme={null} // Basic configuration do { try portal.setPasskeyConfiguration( relyingParty: "portalhq.io", webAuthnHost: "backup.web.portalhq.io" ) } catch { print("Error configuring passkey authentication: \(error)") } // Complete setup with authentication anchor if #available(iOS 16, *) { do { // Configure passkey settings try portal.setPasskeyConfiguration( relyingParty: "portalhq.io", webAuthnHost: "backup.web.portalhq.io" ) // Set the authentication anchor (typically your main window) try portal.setPasskeyAuthenticationAnchor(view.window!) // Now you can use passkey-based backup/recovery try await portal.backupWallet(.Passkey) { status in print("Backup status: \(status)") } } catch { print("Error setting up passkey authentication: \(error)") } } ``` **Implementation Flow** 1. Check iOS version compatibility 2. Configure passkey settings using this method 3. Set the authentication anchor using `setPasskeyAuthenticationAnchor` 4. Use passkey-based backup or recovery methods **Security Considerations** * The relying party identifier is crucial for security and should match your app's domain * Passkeys provide a secure, phishing-resistant alternative to passwords * Each passkey is unique to the combination of user account, relying party, and device **Related Documentation** For more information about passkey integration, see: * [Passkey + Enclave Backup](../guide/back-up-a-wallet) * [Configuring an associated domain](https://developer.apple.com/documentation/xcode/configuring-an-associated-domain#Provide-an-Apple-App-Site-Association-file) * [Supporting associated domains](https://developer.apple.com/documentation/xcode/supporting-associated-domains#Add-the-associated-domain-file-to-your-website) * [Passkey Authentication Anchor](./setpasskeyauthenticationanchor) # setPassword Source: https://docs.portalhq.io/sdks/ios/reference/setpassword Sets the password used for the Password backup method. This method configures the password that will be used to encrypt and decrypt wallet backups using password-based key derivation. **Function Signature** ```swift theme={null} public func setPassword( _ value: String ) throws ``` **Parameters** * `value`: The password string used to generate a key for backup encryption/decryption via password-based key derivation. This should be a secure password that the user can reliably remember or safely store. **Throws** * `MpcError.backupMethodNotRegistered` if the Password backup method has not been registered using `registerBackupMethod` **Notes** * Must be called before using the `.Password` backup method for wallet backup or recovery operations * The password should be securely stored or provided by the user when needed * Should be used in conjunction with registering the Password backup method **Example Usage** ```swift theme={null} // Basic usage do { try portal.setPassword("mySecurePassword123!") } catch { print("Error setting password: \(error)") } // Complete setup with password backup do { // First register the password storage method let passwordStorage = PasswordStorage() portal.registerBackupMethod(.Password, withStorage: passwordStorage) // Set the password try portal.setPassword("mySecurePassword123!") // Now you can use password-based backup let backup = try await portal.backupWallet(.Password) { status in switch status.status { case .readingShare: print("Reading share...") case .encryptingShare: print("Encrypting with password...") case .storingShare: print("Storing encrypted backup...") case .done: print("Backup complete!") default: break } } // Complete the backup process try await backup.storageCallback() } catch { print("Error in password backup process: \(error)") } // Example with password recovery do { // Set up password for recovery try portal.setPassword("mySecurePassword123!") // Recover wallet using password let recovered = try await portal.recoverWallet( .Password, withCipherText: backupCipherText ) { status in print("Recovery status: \(status)") } print("Recovered wallet with address: \(recovered.ethereum)") } catch { print("Error in password recovery process: \(error)") } ``` **Security Best Practices** 1. Use strong passwords that meet these criteria: * Minimum length of 12 characters * Mix of uppercase and lowercase letters * Include numbers and special characters * Avoid common patterns or personal information 2. Password Storage: * Never store the password in plain text * Consider using the iOS Keychain for secure storage * Implement appropriate password recovery mechanisms 3. User Experience: * Validate password strength during input * Provide clear feedback on password requirements * Consider implementing biometric authentication for accessing stored passwords **Implementation Flow** 1. Register the Password backup method 2. Set the password using this method 3. Use the password for backup or recovery operations 4. Implement secure password storage and recovery **Related Documentation** For more information about password-based backup and security, see: * [Password/Pin Backup](../guide/back-up-a-wallet) # deposit Source: https://docs.portalhq.io/sdks/ios/reference/yielddeposit Resolves a yield, builds the enter action, and signs and submits every transaction in one call. **Function Signature** ```swift theme={null} public func deposit(params: YieldDepositParams, options: YieldSubmitOptions?) async throws -> YieldDepositResult ``` A convenience overload drops the options argument: ```swift theme={null} public func deposit(params: YieldDepositParams) async throws -> YieldDepositResult ``` **Description** Runs a complete deposit into a yield opportunity: resolves the yield, builds the enter action, then for each returned transaction signs it, submits it, waits for on-chain confirmation, and reports the hash back to Yield.xyz. Transactions execute sequentially. If one is confirmed as failed on-chain, execution stops immediately and the result carries `.failed`. If a confirmation times out, execution stops and the result carries `.partialSuccess`. The wallet address is resolved from your `Portal` instance for the chain the yield resolves to — there is no `address` parameter. **Parameters** `YieldDepositParams`: | Parameter | Type | Required | Description | | ----------- | ------------------------- | -------- | ---------------------------------------------------------------------------------------- | | `target` | `YieldActionTarget` | Yes | `.yieldId(String)`, or `.chainAndToken(chain:token:)` where `chain` is a full CAIP-2 id. | | `amount` | `String` | Yes | Amount to deposit. Merged into the action arguments. | | `arguments` | `YieldXyzEnterArguments?` | No | Protocol-specific inputs such as `validatorAddress` for native staking. | `YieldSubmitOptions`: | Option | Type | Default | Description | | --------------------- | ---------------------------------- | ------- | ------------------------------------------------------------------------------------------------ | | `onProgress` | `((YieldSubmitProgress) -> Void)?` | `nil` | Fired per transaction with `.signing`, `.submitted`, `.confirming`, `.confirmed`. | | `pollIntervalSeconds` | `Int` | `4` | Seconds between confirmation polls. Clamped to a minimum of `1`. | | `timeoutSeconds` | `Int` | `900` | Seconds before a transaction is treated as uncertain. Clamped to at least `pollIntervalSeconds`. | These are seconds. The Android SDK uses milliseconds for the same two options. **Returns** **`YieldDepositResult`**: | Property | Type | Description | | ------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------- | | `hashes` | `[String]` | Submitted transaction hashes, in order. Present regardless of outcome. | | `yieldId` | `String` | The resolved yield id. | | `status` | `YieldSubmitResultStatus` | `.success`, `.partialSuccess`, or `.failed`. | | `chain` | `String?` | Set only when resolved via `.chainAndToken`. | | `token` | `String?` | Set only when resolved via `.chainAndToken`. | | `yieldOpportunityDetails` | `YieldOpportunityDetails` | Action metadata: `yieldId`, `intent`, `type`, `executionPattern`, `status`, `amount`, `amountUsd`. | A non-empty `hashes` array does not mean success — hashes are recorded at submission, before the outcome is known. Always branch on `status`. **Throws** Throws `YieldXyzError`. The cases most likely here: | Case | When | | --------------------------------- | --------------------------------------------------------------------- | | `invalidChainId(String)` | `chain` was not a full CAIP-2 id. | | `noYieldForChainToken(String)` | No default yield matches that chain and token. | | `yieldNotFound(String)` | The yield id does not exist. | | `addressUnavailable(String)` | No wallet address for the resolved chain. | | `noTransactions` | The action response contained no transactions. | | `missingTransactionField(String)` | A transaction was missing a required field. | | `unsupportedNetwork(String)` | The action resolved to a network the high-level flow cannot sign for. | | `invalidSignResponse` | Signing returned an unusable response. | | `transactionFailed(String)` | A transaction failed on-chain. Carries the hash. | | `apiError(String)` | The backend returned an error payload. | **Example Usage** ```swift theme={null} import PortalSwift do { let result = try await portal.yield.yieldxyz.deposit( params: YieldDepositParams( target: .yieldId("ethereum-sepolia-link-aave-v3-lending"), amount: "0.001" ), options: YieldSubmitOptions( onProgress: { progress in print("[deposit] \(progress.step.rawValue) \(progress.index + 1)/\(progress.total)") }, pollIntervalSeconds: 4, timeoutSeconds: 300 ) ) switch result.status { case .success: print("Confirmed: \(result.hashes)") case .partialSuccess: print("Stopped early: \(result.hashes) — check the yield balance") case .failed: print("Failed on-chain: \(result.hashes.last ?? "none")") } } catch let error as YieldXyzError { print("Deposit failed: \(error.errorDescription ?? "unknown")") } ``` **Related Documentation** * [Earn with Yield.xyz](../guide/yield-xyz) * [withdraw](./yieldwithdraw) * [getValidators](./yieldgetvalidators) * [Yield.xyz Integration](/integrations/Yield/yield-xyz) # getValidators Source: https://docs.portalhq.io/sdks/ios/reference/yieldgetvalidators Returns the available validators for a native-staking yield. **Function Signature** ```swift theme={null} public func getValidators(yieldId: String) async throws -> [YieldXyzValidator] ``` Available on both the namespace and the provider. `portal.yield.getValidators(yieldId:)` is a passthrough to `portal.yield.yieldxyz.getValidators(yieldId:)`. **Description** Fetches the validators available for a native-staking yield. Use the returned addresses to populate `arguments.validatorAddress` on a [`deposit`](./yielddeposit), or to drive a validator picker in your UI. **Parameters** | Parameter | Type | Required | Description | | --------- | -------- | -------- | ----------------------------------------------------------------------- | | `yieldId` | `String` | Yes | The yield identifier, for example `"monad-testnet-mon-native-staking"`. | **Returns** **`[YieldXyzValidator]`** Only `address` is non-optional. Every other field depends on the protocol and may be absent: | Property | Type | Description | | --------------------------- | --------------------- | ----------------------------------------------------------------------------------- | | `address` | `String` | Validator address. The only guaranteed field. | | `name` | `String?` | Display name. | | `logoURI` | `String?` | Logo URL. | | `website` | `String?` | Validator website. | | `rewardRate` | `YieldXyzRewardRate?` | Current reward rate. | | `provider` | `YieldXyzProvider?` | Provider metadata — `name`, `uniqueId`, `website`, `rank`, `preferred`, `revshare`. | | `commission` | `Double?` | Commission rate. | | `tvlUsd` / `tvl` / `tvlRaw` | `String?` | Total value locked, in various denominations. | | `votingPower` | `Double?` | Share of total voting power. | | `preferred` | `Bool?` | Whether the validator is preferred. | | `minimumStake` | `String?` | Minimum stake accepted. | | `remainingPossibleStake` | `String?` | Remaining capacity. | | `remainingSlots` | `Int?` | Remaining delegation slots. | | `nominatorCount` | `Int?` | Number of nominators. | | `status` | `String?` | Validator status. | | `providerId` | `String?` | Provider identifier. | | `pricePerShare` | `String?` | Price per share. | | `subnetId` / `subnetName` | `Int?` / `String?` | Subnet identifiers, where applicable. | | `marketCap` | `String?` | Market capitalization. | | `tokenSymbol` | `String?` | Staked token symbol. | **Throws** | Case | When | | ------------------------------------ | ------------------------------------------------------------------------------ | | `YieldXyzError.noValidators(String)` | The response contained no validators, or an empty list. Carries the `yieldId`. | | `YieldXyzError.apiError(String)` | The backend returned an error payload. Carries the message. | Network errors propagate as thrown errors. **Example Usage** ```swift theme={null} import PortalSwift do { let validators = try await portal.yield.getValidators(yieldId: "monad-testnet-mon-native-staking") for validator in validators { print(validator.address, validator.name ?? "unnamed", validator.commission ?? 0) } } catch YieldXyzError.noValidators(let yieldId) { print("No validators for \(yieldId) — it may not be a native-staking yield") } catch { print("getValidators failed: \(error)") } ``` Using a validator in a deposit: ```swift theme={null} let validators = try await portal.yield.getValidators(yieldId: yieldId) guard let validator = validators.first else { throw YieldXyzError.noValidators(yieldId) } let result = try await portal.yield.yieldxyz.deposit( params: YieldDepositParams( target: .yieldId(yieldId), amount: "1.0", arguments: YieldXyzEnterArguments(validatorAddress: validator.address) ) ) ``` **Related Documentation** * [Earn with Yield.xyz](../guide/yield-xyz) * [deposit](./yielddeposit) * [withdraw](./yieldwithdraw) * [Yield.xyz Integration](/integrations/Yield/yield-xyz) # withdraw Source: https://docs.portalhq.io/sdks/ios/reference/yieldwithdraw Resolves a yield, builds the exit action, and signs and submits every transaction in one call. **Function Signature** ```swift theme={null} public func withdraw(params: YieldWithdrawParams, options: YieldSubmitOptions?) async throws -> YieldWithdrawResult ``` A convenience overload drops the options argument: ```swift theme={null} public func withdraw(params: YieldWithdrawParams) async throws -> YieldWithdrawResult ``` **Description** Runs a complete withdrawal from a yield position: resolves the yield, builds the exit action, then for each returned transaction signs it, submits it, waits for on-chain confirmation, and reports the hash back to Yield.xyz. Behavior, parameters, and result shape are identical to [`deposit`](./yielddeposit) — `YieldWithdrawParams` and `YieldWithdrawResult` are type aliases for `YieldDepositParams` and `YieldDepositResult`. Transactions execute sequentially. A transaction confirmed as failed on-chain stops execution with `.failed`; a confirmation timeout stops execution with `.partialSuccess`. The wallet address is resolved from your `Portal` instance for the chain the yield resolves to — there is no `address` parameter. **Parameters** `YieldWithdrawParams` (alias for `YieldDepositParams`): | Parameter | Type | Required | Description | | ----------- | ------------------------- | -------- | ---------------------------------------------------------------------------------------- | | `target` | `YieldActionTarget` | Yes | `.yieldId(String)`, or `.chainAndToken(chain:token:)` where `chain` is a full CAIP-2 id. | | `amount` | `String` | Yes | Amount to withdraw. Merged into the action arguments. | | `arguments` | `YieldXyzEnterArguments?` | No | Protocol-specific inputs. | `YieldSubmitOptions`: | Option | Type | Default | Description | | --------------------- | ---------------------------------- | ------- | ------------------------------------------------------------------------------------------------ | | `onProgress` | `((YieldSubmitProgress) -> Void)?` | `nil` | Fired per transaction with `.signing`, `.submitted`, `.confirming`, `.confirmed`. | | `pollIntervalSeconds` | `Int` | `4` | Seconds between confirmation polls. Clamped to a minimum of `1`. | | `timeoutSeconds` | `Int` | `900` | Seconds before a transaction is treated as uncertain. Clamped to at least `pollIntervalSeconds`. | **Returns** **`YieldWithdrawResult`** (alias for `YieldDepositResult`): | Property | Type | Description | | ------------------------- | ------------------------- | ---------------------------------------------------------------------- | | `hashes` | `[String]` | Submitted transaction hashes, in order. Present regardless of outcome. | | `yieldId` | `String` | The resolved yield id. | | `status` | `YieldSubmitResultStatus` | `.success`, `.partialSuccess`, or `.failed`. | | `chain` | `String?` | Set only when resolved via `.chainAndToken`. | | `token` | `String?` | Set only when resolved via `.chainAndToken`. | | `yieldOpportunityDetails` | `YieldOpportunityDetails` | Action metadata. | A non-empty `hashes` array does not mean success. Always branch on `status`. **Throws** Throws `YieldXyzError` — the same cases as [`deposit`](./yielddeposit), including `invalidChainId`, `noYieldForChainToken`, `yieldNotFound`, `addressUnavailable`, `noTransactions`, `unsupportedNetwork`, `transactionFailed`, and `apiError`. **Example Usage** ```swift theme={null} import PortalSwift do { let result = try await portal.yield.yieldxyz.withdraw( params: YieldWithdrawParams( target: .yieldId("ethereum-sepolia-link-aave-v3-lending"), amount: "0.001" ) ) print("Status: \(result.status.rawValue)") print("Hashes: \(result.hashes)") } catch let error as YieldXyzError { print("Withdraw failed: \(error.errorDescription ?? "unknown")") } ``` **Related Documentation** * [Earn with Yield.xyz](../guide/yield-xyz) * [deposit](./yielddeposit) * [getValidators](./yieldgetvalidators) * [Yield.xyz Integration](/integrations/Yield/yield-xyz) # SDK Quickstart Source: https://docs.portalhq.io/sdks/quickstart This quick start guide will get you up and running with any of the Portal SDKs. ## Overview Portal offers SDKs for Web, iOS, Android, React Native, and Flutter. To install the chosen Portal SDK in your app, simply add the Portal package to your project. ```bash theme={null} yarn add @portal-hq/web ``` ### Using Swift Package Manager (SPM) 1. In Xcode, right-click on your project in the Project Navigator 2. Select "Add Packages..." 3. In the search field, enter either: * Package name: `PortalSwift` * Repository URL: `https://github.com/portal-hq/PortalSwift` 4. Select your desired version 5. Click "Add Package" ### Using CocoaPods 1. Add the following line to your `Podfile`: ```ruby theme={null} pod 'PortalSwift', :git => 'https://github.com/portal-hq/PortalSwift.git' ``` 2. In Terminal, navigate to your project directory and run: ```bash theme={null} pod install ``` 3. After installation: * Close your `.xcodeproj` file * Open the newly generated `.xcworkspace` file * ⚠️ Important: Always use the `.xcworkspace` file for development when using CocoaPods Update the `dependencies` in your app level `build.gradle` to include the `portal-android` dependency ```gradle theme={null} implementation "io.portalhq.android:portal-android:X.X.X" ``` The basic Portal setup consists of three packages: * `@portal-hq/core` - The core Portal library. * `@portal-hq/keychain` - An adapter for storing MPC signing shares on-device. * `@portal-hq/gdrive-storage` - An adapter for storing MPC backup shares off-device. These modules allow you to initialize `Portal` in your app. ```bash theme={null} yarn add @portal-hq/core @portal-hq/keychain @portal-hq/gdrive-storage react-native-keychain cd ios & pod install # (FOR iOS use only) ``` Add the `portal_flutter` package to your `pubspec.yaml`: ```yaml theme={null} dependencies: portal_flutter: ^LATEST_VERSION ``` Then run: ```bash theme={null} flutter pub get ``` The Portal Flutter SDK is a federated plugin with separate Android and iOS implementations. See the [Getting Started](./flutter/guide/getting-started) guide for platform-specific configuration. ## Authentication The Portal SDK is initialized with a Client API Key or Client Session Token. You can get a test Client API Key from the [Portal Admin Dashboard](https://app.portalhq.io) in the **`Settings`** -> **`Test Client API Keys`** section. Simply click the **`New +`** button. A modal will then be presented, allowing you to copy your test Client API Key. You can repeat this process as many times as you need to. ## Initializing Portal To initialize **`Portal`** in your application, create a new instance of the **`Portal`** class included in the package dependency you added. You can provide your new test Client API Key as the **`apiKey`** argument during initialization. **`rpcConfig`** is a map of [CAIP-2 Chain IDs](../resources/chain-id-formatting#caip-2-chain-id-format) to their respective RPC URLs. Portal offers custom approval flows during the signing process, but for now, we can auto-approve all signatures with the **`autoApprove`** argument. ```typescript theme={null} import Portal from '@portal-hq/web' const portal = new Portal({ apiKey: 'YOUR_TEST_CLIENT_API_KEY', autoApprove: true, rpcConfig: { 'eip155:10143': 'YOUR-INFURA-OR-ALCHEMY-URL' }, }) ``` ```swift theme={null} import PortalSwift let portal = try Portal("YOUR_TEST_CLIENT_API_KEY", autoApprove: true) ``` ```kotlin theme={null} import io.portalhq.android.Portal val portal = Portal("YOUR_TEST_CLIENT_API_KEY", autoApprove = true) ``` ```typescript theme={null} import Portal from '@portal-hq/core' const portal = new Portal({ apiKey: 'YOUR_TEST_CLIENT_API_KEY', autoApprove: true, gatewayConfig: { ['eip155:10143']: 'https://api.portalhq.io/rpc/v1/eip155/10143', ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp']: 'https://api.portalhq.io/rpc/v1/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1']: 'https://api.portalhq.io/rpc/v1/solana/EtWTRABZaYq6iMfeYKouRu166VU2xqa1', }, }) ``` ```dart theme={null} import 'package:portal_flutter/portal_flutter.dart'; final portal = Portal(); await portal.initialize( apiKey: 'YOUR_TEST_CLIENT_API_KEY', autoApprove: true, rpcConfig: { 'eip155:10143': 'https://api.portalhq.io/rpc/v1/eip155/10143', 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1': 'https://api.portalhq.io/rpc/v1/solana/EtWTRABZaYq6iMfeYKouRu166VU2xqa1', }, ); ``` ## Create a wallet To create a new wallet, use the **`portal.createWallet()`** function. On React Native, iOS, and Android, you can optionally provide a callback to receive [status updates](./react-native/guide/mpc-progress-callbacks) during wallet creation. ```typescript theme={null} const eip155Address = await portal.createWallet() console.log(`My Portal EVM address: ${eip155Address}`) console.log(`My Portal Solana address: ${await portal.getSolanaAddress()}`) ``` ```swift theme={null} let addresses = try await portal.createWallet() print("My Portal EVM address: \(addresses.ethereum)") print("My Portal Solana address: \(addresses.solana)") ``` ```kotlin theme={null} val addresses = portal.createWallet() println("My Portal EVM wallet: ${addresses.ethereumAddress}") print("My Portal Solana wallet: ${addresses.solanaAddress}") ``` ```typescript theme={null} const addresses = await portal.createWallet() console.log(`My Portal EVM address: ${addresses.eip155}`) console.log(`My Portal Solana address: ${addresses.solana}`) ``` ```dart theme={null} final addresses = await portal.createWallet(); print('My Portal EVM address: ${addresses.ethereum}'); print('My Portal Solana address: ${addresses.solana}'); ``` Want faster wallet creation? Enable the [`usePreGeneratedWallet`](../resources/pre-generated-wallets) feature flag—no changes to the code above are needed. ## Receive testnet tokens Now that you have a wallet, the next step is to get test tokens for it. You can fund your wallet using **`portal.receiveTestnetAsset`**. If you are looking for a greater variety of test tokens, we recommend exploring our [faucets page](../resources/testnet-faucets). The `chainId` will need to be a [CAIP-2](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md) compliant Chain ID. For more info on Chain ID formatting, see [this doc](../resources/chain-id-formatting). ```typescript theme={null} const chainId = "eip155:10143" // Monad Testnet const params = { amount: "0.01", // You will receive 0.01 MON token: "NATIVE" // Token, use "NATIVE" for the chain's native token } // Fund your Portal wallet const response = await portal.receiveTestnetAsset(chainId, params) console.log(`✅ Transaction hash: ${response.data.txHash}`) ``` ```swift theme={null} let chainId = "eip155:10143" // Monad Testnet let params = FundParams( amount: "0.01", // You will receive 0.01 MON token: "NATIVE" // Token, use "NATIVE" for the chain's native token ) // Fund your Portal wallet let response = portal.receiveTestnetAsset(chainId, params) print("✅ Transaction hash: \(response.data.txHash)") ``` ```kotlin theme={null} val chainId = "eip155:10143" // Monad Testnet val params = FundParams( amount = "0.01", // You will receive 0.01 MON token = "NATIVE" // Token, use "NATIVE" for the chain's native token ) // Fund your Portal wallet val response = portal.receiveTestnetAsset(chainId, params) println("✅ Transaction hash: ${response.data.txHash}") ``` ```typescript theme={null} const chainId = "eip155:10143" // Monad Testnet const params = { amount: "0.01", // You will receive 0.01 MON token: "NATIVE" // Token, use "NATIVE" for the chain's native token } // Fund your Portal wallet const response = await portal.receiveTestnetAsset(chainId, params) console.log("✅ Transaction hash: ${response.data.txHash}") ``` ```dart theme={null} final chainId = 'eip155:10143'; // Monad Testnet // Fund your Portal wallet final response = await portal.receiveTestnetAsset( chainId: chainId, amount: '0.01', // You will receive 0.01 MON token: 'NATIVE', // Use "NATIVE" for the chain's native token ); print('✅ Transaction hash: ${response.transactionHash}'); ``` ## Send tokens Portal provides two ways to send transactions: 1. **`portal.sendAsset()`** - A simple method for sending tokens from your Portal wallet. 2. **`portal.request()`** - Direct access to the underlying web3 provider for custom transactions. (You can learn more about this method [here](./react-native/guide/sign-a-transaction).) For most use cases, we recommend using **`portal.sendAsset()`** as shown in the examples below. ### EVM ```typescript theme={null} const chainId = "eip155:10143" // Monad Testnet const params = { amount: "0.0001", // Sends 0.0001 MON to: "0xDestinationAddress", // The recipient address token: "NATIVE" // Token, use "NATIVE" for the chain's native token } // Send the tokens const txHash = await portal.sendAsset(chainId, params) console.log(`✅ Transaction hash: ${txHash}`) ``` ```swift theme={null} let chainId = "eip155:10143" // Monad Testnet let params = SendAssetParams( to: "0xDestinationAddress", // The recipient address amount: "0.0001", // Sends 0.0001 MON token: "NATIVE", // Token, use "NATIVE" for the chain's native token signatureApprovalMemo: "test" // Optional signature approval memo to use for the request ) // Send the tokens let txHash = try await portal.sendAsset(chainId, params) print("✅ Transaction hash: \(txHash)") ``` ```kotlin theme={null} val chainId = "eip155:10143" // Monad Testnet val params = SendAssetParams( amount = "0.0001", // Sends 0.0001 MON to = "0xDestinationAddress", // The recipient address token = "NATIVE" // Token, use "NATIVE" for the chain's native token ) // Send the tokens val txHash = portal.sendAsset(chainId, params) println("✅ Transaction hash: ${txHash}") ``` ```typescript theme={null} const chainId = "eip155:10143" // Monad Testnet const params = { amount: "0.0001", // Sends 0.0001 MON to: "0xDestinationAddress", // The recipient address token: "NATIVE" // Token, use "NATIVE" for the chain's native token } // Send the tokens const txHash = await portal.sendAsset(params.to, params.token, params.amount, chainId) console.log(`✅ Transaction hash: ${txHash}`) ``` ```dart theme={null} final chainId = 'eip155:10143'; // Monad Testnet // Send the tokens final txHash = await portal.sendAsset( chainId: chainId, to: '0xDestinationAddress', // The recipient address amount: '0.0001', // Sends 0.0001 MON token: 'NATIVE', // Use "NATIVE" for the chain's native token ); print('✅ Transaction hash: $txHash'); ``` **Account Abstraction clients:** `txHash` is a **UserOperation hash**, not an on-chain transaction hash — it will **not** resolve on a block explorer such as Etherscan or Monadscan. The on-chain transaction hash is only assigned once the bundler includes the UserOperation on-chain. Look up the UserOperation hash on a UserOp explorer such as [JiffyScan](https://jiffyscan.xyz/) to find the resulting transaction hash. See [Account abstraction](../resources/account-abstraction). ### Solana You will need **`SOL`** to submit a Solana transaction, which is not currently supported by **`portal.receiveTestnetAsset`**. You can find a faucet to get test **`SOL`** tokens [here](../resources/testnet-faucets). ```typescript theme={null} const chainId = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"; // Solana Devnet const params = { amount: "0.0001", // Sends 0.0001 SOL to: "0xDestinationAddress", // The recipient address token: "NATIVE" // Token, use "NATIVE" for the chain's native token } // Send the tokens const txHash = await portal.sendAsset(chainId, params) console.log(`✅ Transaction hash: ${txHash}`) ``` ```swift theme={null} let chainId = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" // Solana Devnet let params = SendAssetParams( amount: "0.0001", // Sends 0.0001 SOL to: "0xDestinationAddress", // The recipient address token: "NATIVE" // Token, use "NATIVE" for the chain's native token ) // Send the tokens let response = try await portal.sendAsset(chainId, params) print("✅ Transaction hash: \(response.result)") ``` ```kotlin theme={null} val chainId = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" // Solana Devnet val params = SendAssetParams( amount = "0.0001", // Sends 0.0001 SOL to = "0xDestinationAddress", // The recipient address token = "NATIVE" // Token, use "NATIVE" for the chain's native token ) // Send the tokens val txHash = portal.sendAsset(chainId, params) print("✅ Transaction hash: \(txHash)") ``` ```typescript theme={null} const chainId = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"; // Solana Devnet const params = { amount: "0.0001", // Sends 0.0001 SOL to: "0xDestinationAddress", // The recipient address token: "NATIVE" // Token, use "NATIVE" for the chain's native token } // Send the tokens const txHash = await portal.sendAsset(params.to, params.token, params.amount, chainId) console.log(`✅ Transaction hash: ${txHash}`) ``` ```dart theme={null} final chainId = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1'; // Solana Devnet // Send the tokens final txHash = await portal.sendAsset( chainId: chainId, to: 'DestinationAddress', // The recipient address (Solana format) amount: '0.0001', // Sends 0.0001 SOL token: 'NATIVE', ); print('✅ Transaction hash: $txHash'); ``` *** You just created a Portal client, created their wallet, accessed their addresses, received testnet tokens, and sent tokens on both Monad Testnet and Solana Devnet! 🎉 Next we recommend going through the complete SDK guides — [iOS](./ios/guide/create-a-wallet), [Android](./android/guide/create-a-wallet), [Web](./web/guide/create-a-wallet), [React Native](./react-native/guide/create-a-wallet), or [Flutter](./flutter/guide/create-a-wallet) — where we go into much more detail. # Add custom signature hooks Source: https://docs.portalhq.io/sdks/react-native/guide/add-custom-signature-hooks ## What are custom signature hooks? **Custom signature hooks** allow companies to trigger approval business logic based on parameters including transaction value, transaction volume, types of signature, and more. The business logic can be anything that helps the company and/or end user make good decisions during the signing request process. A couple examples are auto-approvals (eg, auto-approving transactions below a certain value or volume) and two-factor authentication (eg, requiring approval for transactions above a certain limit). Another option are security checks, either internal or external, to prevent malicious activity. ## Why use custom signature hooks? There are a few common reasons developers use custom signature hooks. For example: * **Approving transactions** - Most developers will prompt a user for approval before signing a message from a dApp or Wallet Connect * **Adding additional authentication** - Developers can add extra authentication steps such as MFA to riskier transactions * **Checking transaction sizes** - Developers can perform checks on transaction sizes to trigger specific authentication steps Custom signature hooks can be passive, invisible to the user, or active, designed into the frontend UX with which customers engage. A **passive** flow will generally be used to facilitate company goals, such as ensuring a user isn’t on a deny list. As such, the company can create a custom security check that is activated when a customer signs a transaction without them knowing. Transactions will continue as normal unless the user is found to be denied use of the app, at which time it will be rejected. An **active** flow is visible to the user and can be used to offer a superior user experience. For example, companies can trigger MFA for transactions above a certain size, an example we will dive into at the end of this guide. ## How do custom signature hooks work? The Portal approval flow is driven by a series of event hooks that take place throughout the lifecycle of a Provider requests. These hooks are as follows: * `portal_signingRequested` - Emitted by the Portal Provider when a new signing request is received by the Provider * `portal_signingApproved` - Emitted by you when signing a given request is approved * `portal_signingRejected` - Emitted by you when signing a given request is rejected ## Designing your flow The expectation of this flow is that your code binds to the `portal_signingRequested` event using the following pattern: ```typescript theme={null} import { PortalRequestMethod } from '@portal-hq/core' portal.provider.on('portal_signingRequested', ({ method, params }) => { // `method` will be a PortalRequestMethod enum **value**, i.e., the string defined in the enum // (e.g., PortalRequestMethod.EthSendTransaction === 'eth_sendTransaction'). // You can safely compare using `=== PortalRequestMethod.EthSendTransaction`. // On approval portal.provider.emit('portal_signingApproved', { method, params }) // On rejection portal.provider.emit('portal_signingRejected', { method, params }) }) ``` As demonstrated by this example, you can implement any business logic you feel is required to make good decisions about what signing requests to process. You can prompt your user for approval, you can make requests to your backend to run some code remotely, etc. The only restriction of this flow is that when you have determined whether a signing request should be approved or rejected, you should emit either the `portal_signingRejected` or `portal_signingApproved` event. When emitting these events, the Provider uses the `method` and `params` to determine if we are indeed approving or rejecting the correct request, so it is vital that you pass the `method` and `params` to your `emit()` calls. ### A more real-world example: MFA for large transactions A common use case is to trigger additional approval from users for large transactions. The implementation of this using custom signature hooks might look something like this: ```typescript theme={null} import { PortalRequestMethod } from '@portal-hq/core' portal.provider.on('portal_signingRequested', async ({ method, params }) => { // method is a PortalRequestMethod enum value // Check the transaction size const isLargeTransaction = checkIfLargeTransaction(method, params) // Request MFA approval from user if large transaction let isMfaApproved = false if (isLargeTransaction) { isMfaApproved = await getMfaApproval() } // Approve transaction request if MFA was successful if (isMfaApproved) { portal.provider.emit('portal_signingApproved', { method, params }) } else { portal.provider.emit('portal_signingRejected', { method, params }) } }) ``` The outcome would look like something like this: ![](https://lh4.googleusercontent.com/IUz10Pt88VpjlIwlhswdWv9Uns9Aui_adLXQGFOufIitcLENyWELQcwAjR-uhoFIT5EsneS0ZW7vJ8khMr_2Vtn-2reeudbwmRMsTmRRW3S7yeMVI1UkoAZ5zcWSdKn9hCSrYB0271KH9hlLIB3ePfc) # Back up a wallet Source: https://docs.portalhq.io/sdks/react-native/guide/back-up-a-wallet This guide will walk you through how to create a backup of a Portal client's wallet. ## Portal-Managed Backups Portal lets you securely back up your users' MPC wallets so they can recover their wallets even if their device is lost or damaged. By default, Portal encrypts and stores both backup shares ("Portal-Managed Backups"): 1. The **client backup share** is encrypted on the user's device, with the encryption key stored using their chosen backup method (Google Drive, iCloud, Password, Passkey, or Firebase Auth). The encrypted share is then stored by Portal. 2. The **custodian backup share** is encrypted and stored by Portal, with the encryption key stored in our KMS infrastructure. By default, Portal manages storing both the encrypted client backup share and the custodian backup share for you. If you prefer to store and manage the backup shares in your own infrastructure instead of using Portal-Managed Backups, see our [Self-Managed Backups](../../../resources/self-managed-backups) guide. Both the client backup share and the custodian backup share are necessary to recover a Portal wallet. ## Backup Methods You can choose one or more backup methods for storing the encryption key for the client backup share. ### Passkey + Enclave Allow customers to create a native passkey on their device that is used to authenticate into a secure enclave that holds the encryption key for the user. Customer's passkeys are backed up to the native cloud storage for their device. #### Implementation Requirements 1. Install `react-native-passkeys` and `@portal-hq/passkey-storage` 2. Initialize passkey storage as a backup option in the Portal Config Object. 3. Configuring the relying party #### Relying party A relying party is a trusted domain that is tied to the public key credentials of your users for their passkey . We offer the option to use `portalhq.io` as your relying party domain. It requires you to add `portalhq.io` as an Associated Domain in your iOS application and share your team id + application bundle id. If you already have your domain as a `webcredential` for your application then you can simply pass in your domain as the relying party and everything should work. #### Use Portal as your relying party 1. Add `portalhq.io` as a web credential domain in your app. 2. Share your app bundle id and team id with the Portal Team. 1. Add `portalhq.io` as an included domain in your manifest file. 2. Share your applications SHA fingerprint and namespace with the Portal Team. #### Use your own relying party Ensure you have set up your associate domain correctly in your app and that you are serving an aasa file from your relying party domain. You will need to be sure you have the `webcredential` field set properly for your app in your aasa file. Resources from apple: * [Configuring an associated domain](https://developer.apple.com/documentation/xcode/configuring-an-associated-domain#Provide-an-Apple-App-Site-Association-file) * [Supporting associated domains](https://developer.apple.com/documentation/xcode/supporting-associated-domains#Add-the-associated-domain-file-to-your-website) Ensure you have set up your digital asset links url correctly in your app and that you are serving a digital asset links file from the URL you specified in the asset statement in your manifest file. Read more about setting this up from the android docs: [Configuring your Digital Asset Links file](https://developer.android.com/training/sign-in/passkeys#add-support-dal) ### Relying Party Origins Regardless of the relying party decision you make above, you will need to set the relying party origin to: ``` android:apk-key-hash: ``` Read more info [here](https://developer.android.com/training/sign-in/passkeys#verify-origin) on how to get the SHA256 hash of the apk signing cert. ### Password/PIN Allow users to create a password/pin. Users can either remember the password or store it in a password storage manager. #### Implementation Requirements 1. Create a UI for password input. 2. Enforce password requirements. Customer can choose between password, PIN code, passcode, or any other text-based input. 3. If user forgets password there are no additional recovery options. ```typescript theme={null} import axios from 'axios' import React, { FC } from 'react' import { BackupMethods, usePortal } from '@portal-hq/core' import { Button, View } from 'react-native' const BackupButton: FC = () => { const [password, setPassword] = useState('') const portal = usePortal() const handleBackup = async () => { // Get an encrypted client backup share from running backup. const cipherText = await portal.backupWallet( BackupMethods.Password, (status) => { console.log('Backup Status: ', status) }, { passwordStorage: { password: password } }, ) try { // Send the backup share to your API and store it. await axios.post('{your_server}/users/[userId]/user-backup-shares', { data: { backupMethod: "PASSWORD", cipherText } }) // ✅ Notify Portal that the user backup share was stored! 🙌 await portal.api.storedClientBackupShare(true, "PASSWORD") } catch (error) { // ❌ Notify Portal that the user backup share was not stored. await portal.api.storedClientBackupShare(false, "PASSWORD") } } return ( ) } export default BackupButton ``` ```typescript theme={null} import axios from 'axios' import React from 'react' const BackupButton: React.FC = () => { const handleBackup = async () => { // Get an encrypted user backup share from running backup. const { cipherText } = await portal.backupWallet(BackupMethods.passkey) try { // Send the backup share to your API and store it. // This is pseduo code, change it with your URL request to your backend and your expected body data. await axios.post('{your_server}/users/[userId]/user-backup-share', { data: { backupMethod: "PASSKEY", cipherText } }) // ✅ Notify Portal that the user backup share was stored! 🙌 await portal.storedClientBackupShare(true, BackupMethods.passkey) } catch (error) { // ❌ Notify Portal that the user backup share was not stored. await portal.storedClientBackupShare(false, BackupMethods.passkey) } } return ( ) } export default BackupButton ``` #### Custom Domain Passkeys By default, Portal handles passkey operations through our hosted domain (`portalhq.io`). If you want passkeys to be associated with your own domain (e.g., `yourapp.com`), you can configure a custom relying party. **Benefits of using your own domain:** * Passkey prompts display your domain name instead of Portal's * Users see a consistent brand experience * Passkeys are portable across your applications that share the same relying party ##### Setup Requirements To use your own domain for passkeys, you'll need to: 1. **Configure DNS** - Point your passkey subdomain (e.g., `passkeys.yourapp.com`) to Portal's infrastructure 2. **Provision a TLS certificate** - Create a certificate for your subdomain that Portal will store in our secure enclave 3. **Configure CORS** - Allowlist your application origins **Getting Started:** Reach out to the Portal team for instructions on setting up a custom domain, including TLS certificate provisioning for our enclave. ##### Configuration Once your custom domain is set up, configure your passkey options: ```typescript theme={null} import Portal, { BackupMethods } from '@portal-hq/web' import { PasskeyOptions } from '@portal-hq/web/types' const passkeyOptions: PasskeyOptions = { customDomain: 'https://passkeys.yourapp.com', // Your configured subdomain. This will be the same across all environments, even local. relyingPartyId: 'yourapp.com', // Your root domain. For local dev set this to `localhost` relyingPartyName: 'Your App Name', // Displayed in passkey prompts usePopup: false, // Direct WebAuthn calls } const portal = new Portal({ apiKey: 'YOUR_CLIENT_API_KEY', rpcConfig: { 'eip155:11155111': 'YOUR_RPC_URL', }, }) ``` ##### Step 1: Create a Passkey Create a passkey for your user. This can be done separately from the backup flow: ```typescript theme={null} import React from 'react' const CreatePasskeyButton: React.FC = () => { const handleCreatePasskey = async () => { try { // Register a passkey without storing an encryption key yet await portal.registerPasskey(passkeyOptions) console.log('Passkey created successfully') } catch (error) { console.error('Failed to create passkey:', error) } } return ( ) } ``` ##### Step 2: Create a Backup Once a passkey exists, you can create a backup and store the encryption key with it: ```typescript theme={null} import React from 'react' const BackupButton: React.FC = () => { const handleBackup = async () => { try { // Step 1: Generate backup share and encryption key const { encryptionKey } = await portal.generateBackupShare( (status) => console.log('Backup progress:', status) ) // Step 2: Authenticate with passkey and store the encryption key await portal.authenticatePasskeyAndWriteKey(encryptionKey, passkeyOptions) // Portal stores the cipherText automatically console.log('Backup completed successfully') } catch (error) { console.error('Backup failed:', error) } } return ( ) } ``` ```typescript theme={null} import axios from 'axios' import React from 'react' const BackupButton: React.FC = () => { const handleBackup = async () => { try { // Step 1: Generate backup share and encryption key const { cipherText, encryptionKey } = await portal.generateBackupShare( (status) => console.log('Backup progress:', status) ) // Step 2: Authenticate with passkey and store the encryption key await portal.authenticatePasskeyAndWriteKey(encryptionKey, passkeyOptions) // Step 3: Store the cipherText in your backend // This is pseduo code, change it with your URL request to your backend and your expected body data. await axios.post('{your_server}/users/[userId]/user-backup-share', { data: { backupMethod: 'CUSTOM', cipherText } // Note this backup method is CUSTOM not PASSKEY (since its decoupled from mpc) }) // Step 4: Signal to Portal that backup storage is complete await portal.storedClientBackupShare(true, BackupMethods.custom) console.log('Backup completed successfully') } catch (error) { // Signal failure to Portal await portal.storedClientBackupShare(false, BackupMethods.custom) console.error('Backup failed:', error) } } return ( ) } ``` ### Password/PIN Your Portal clients can create a password/PIN. They can either remember the password or store it in a password storage manager. #### Implementation Requirements 1. Create a UI for password input. 2. Enforce password requirements. Customer can choose between password, PIN code, passcode, or any other text-based input. 3. If the user forgets their password, there are no additional recovery options. ```typescript theme={null} import React, { FC, useState } from 'react' import Portal, { BackupMethods } from '@portal-hq/web' const portal = new Portal({ apiKey: 'YOUR_CLIENT_API_KEY', rpcConfig: { 'eip155:11155111': 'YOUR_RPC_URL', }, }) const BackupButton: FC = () => { const [password, setPassword] = useState('') const handleBackup = async () => { // Create a password backup for the wallet. await portal.backupWallet(BackupMethods.password, undefined, { passwordStorage: { password } }) } return (
setPassword(e.target.value)} placeholder="Password/Pin" type="password" value={password} />
) } export default BackupButton ```
```typescript theme={null} import axios from 'axios' import React, { FC, useState } from 'react' import Portal, { BackupMethods } from '@portal-hq/web' const portal = new Portal({ apiKey: 'YOUR_CLIENT_API_KEY', rpcConfig: { 'eip155:11155111': 'YOUR_RPC_URL', }, }) const BackupButton: FC = () => { const [password, setPassword] = useState('') const handleBackup = async () => { // Get an encrypted client backup share from running backup. const { cipherText } = await portal.backupWallet(BackupMethods.password, undefined, { passwordStorage: { password } }) try { // Send the backup share to your API and store it. // This is pseduo code, change it with your URL request to your backend and your expected body data. await axios.post('{your_server}/users/[userId]/user-backup-share', { data: { backupMethod: "PASSWORD", cipherText } }) // ✅ Notify Portal that the user backup share was stored! 🙌 await portal.storedClientBackupShare(true, BackupMethods.password) } catch (error) { // ❌ Notify Portal that the user backup share was not stored. await portal.storedClientBackupShare(false, BackupMethods.password) } } return (
setPassword(e.target.value)} placeholder="Password/Pin" type="password" value={password} />
) } export default BackupButton ```
### Google Drive See the docs on how to [set up Google Drive](../../../resources/backup-options/gdrive). ### Firebase Auth Backup Allow customers to use their existing Firebase Authentication to authenticate into a secure enclave that holds the encryption key for the user. The Portal Web SDK uses Firebase ID tokens to store and retrieve encryption keys from Portal’s token backup service (TBS). This is ideal if your web app already uses Firebase Auth — no additional authentication method is required from your users. See the [Firebase Auth Backup setup guide](../../../resources/backup-options/firebase-byo-auth) for prerequisites and Firebase project configuration. #### Implementation requirements 1. Integrate [Firebase Authentication](https://firebase.google.com/docs/auth/web/start) in your web app (for example with the Firebase JavaScript SDK). 2. Call [`portal.configureFirebaseStorage`](/sdks/web/reference#configurefirebasestorage) with a `getToken` callback that returns a Firebase ID token for the signed-in user, or `null` when no user is signed in. 3. Run backup with `BackupMethods.firebase` only after Firebase storage is configured and the user is signed in. Unlike React Native, the Web SDK does not use a separate `@portal-hq/firebase-storage` package. Firebase backup is built into `@portal-hq/web` via `configureFirebaseStorage`. Your app supplies Firebase Auth; the Portal iframe requests ID tokens from the parent page through a secure `postMessage` bridge. #### Configure Firebase storage Call `configureFirebaseStorage` before `backupWallet` or `recoverWallet` with `BackupMethods.firebase`. You typically do this immediately before the backup or recovery flow, or once after the user signs in to Firebase. ```typescript theme={null} import Portal, { BackupMethods } from '@portal-hq/web' import { getAuth } from 'firebase/auth' const portal = new Portal({ apiKey: 'YOUR_PORTAL_CLIENT_API_KEY', rpcConfig: { 'eip155:11155111': 'YOUR_RPC_URL', }, }) async function configureFirebaseForPortal() { const auth = getAuth() portal.configureFirebaseStorage({ getToken: async (options?: { forceRefresh?: boolean }) => { const user = auth.currentUser if (!user) { return null } // Portal may call this again with forceRefresh after a 401 — forward it to getIdToken. return user.getIdToken(Boolean(options?.forceRefresh)) }, // Optional: override TBS host (defaults to backup.web.portalhq.io) tbsHost: 'backup.web.portalhq.io', }) } ``` The user must be signed in to Firebase before running backup or recovery. If there is no signed-in user, `getToken` should return `null` and the operation will fail. The tab examples below assume a `portal` instance and `configureFirebaseForPortal` helper from the [Configure Firebase storage](#configure-firebase-storage) section above. Ensure the user is signed in to Firebase, configure storage, then run backup. With [Portal-Managed Backups](#portal-managed-backups) (the default), Portal stores the encrypted client backup share for you. ```typescript theme={null} import React from 'react' import { BackupMethods } from '@portal-hq/web' const FirebaseBackupButton: React.FC = () => { const handleBackup = async () => { await configureFirebaseForPortal() const { cipherText, storageCallback } = await portal.backupWallet( BackupMethods.firebase, (status) => { console.log('Backup status:', status) }, ) await storageCallback() console.log('Backup cipherText:', cipherText) } return ( ) } export default FirebaseBackupButton ``` With self-managed storage, you persist the encrypted client backup share (`cipherText`) in your own backend, then tell Portal the write succeeded or failed. See [Self-Managed Backups](../../../resources/self-managed-backups) for the full model. ```typescript theme={null} import axios from 'axios' import React from 'react' import { BackupMethods } from '@portal-hq/web' const FirebaseSelfManagedBackupButton: React.FC = () => { const handleBackup = async () => { await configureFirebaseForPortal() const { cipherText, storageCallback } = await portal.backupWallet( BackupMethods.firebase, (status) => { console.log('Backup status:', status) }, ) try { await axios.post('{your_server}/users/[userId]/user-backup-share', { data: { backupMethod: 'FIREBASE', cipherText }, }) await portal.storedClientBackupShare(true, BackupMethods.firebase) await storageCallback() } catch (error) { await portal.storedClientBackupShare(false, BackupMethods.firebase) } } return ( ) } export default FirebaseSelfManagedBackupButton ``` **Related documentation** * [Firebase Auth Backup option](../../../resources/backup-options/firebase-byo-auth) * [`configureFirebaseStorage` reference](/sdks/web/reference#configurefirebasestorage) # Batch user operations Source: https://docs.portalhq.io/sdks/web/guide/batch-user-operations Build, sign, and broadcast ERC-4337 batch UserOperations with the Web SDK. The Web SDK exposes helpers for building, signing, and broadcasting ERC-4337 batch UserOperations in a single call. These methods are available on AA-enabled clients only and require a CAIP-2 chain ID starting with `eip155:`. Batch UserOperations require [Account Abstraction](../../../resources/account-abstraction) to be enabled for your organization and client. ## sendBatchUserOp Builds, signs, and broadcasts a batch of token transfers as a single UserOperation. Portal's paymaster sponsors the gas; the user pays nothing in native tokens. ```typescript theme={null} import type { SendBatchUserOpRequest } from '@portal-hq/web' const result = await portal.sendBatchUserOp({ chain: 'eip155:10143', transactions: [ { token: 'USDC', value: '5.00', to: '0xAlice...' }, { token: 'USDC', value: '5.00', to: '0xBob...' }, ], signatureApprovalMemo: 'Send USDC to Alice and Bob', // optional }) // result.data.userOpHash — submitted UserOperation hash (not an on-chain tx hash) ``` `result.data.userOpHash` is a UserOperation hash, not an on-chain transaction hash. To wait for on-chain inclusion call `portal.waitForConfirmation(result.data.userOpHash, result.metadata.chainId)`. ## sendBatchedAssets Like `sendBatchUserOp`, but appends a gas-reimbursement transfer to the batch. The paymaster still sponsors the gas; the reimbursement call recovers that cost from the user's smart account, in a fee token of your choice (e.g. USDC). The method runs a **two-pass build**: the first pass estimates the gas cost of the full batch (including the reimbursement call), and the second pass builds the final batch with the real fee amount. ```typescript theme={null} import type { SendBatchedAssetsRequest } from '@portal-hq/web' const chain = 'eip155:10143' const result = await portal.sendBatchedAssets({ chain, transactions: [ { token: 'USDC', value: '10.00', to: '0xAlice...' }, ], gasReimbursement: { feeToken: 'USDC', feeRecipient: '0xYourPlatformWallet', // Portal hands you the estimated gas cost in wei and expects back // a fee-token amount as a decimal string. The conversion is entirely // yours — use any price source (oracle, internal service, API, etc.). convertGasToFeeAmount: async (gasCostWei: bigint) => { const nativeAmount = Number(gasCostWei) / 1e18 const usdPerNative = await yourPricingLayer.getPrice(chain) return (nativeAmount * usdPerNative).toFixed(6) }, bufferBps: 1000, // +10% safety margin on the gas estimate placeholderAmount: '0.01', // used during estimation only }, signatureApprovalMemo: 'Transfer + gas fee', }) ``` **`GasReimbursement` fields:** | Field | Type | Required | Description | | ----------------------- | --------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------- | | `feeToken` | `string` | Yes | Token symbol charged for reimbursement (e.g. `'USDC'`) | | `feeRecipient` | `string` | Yes | EVM address that receives the reimbursement | | `convertGasToFeeAmount` | `(gasCostWei: bigint) => string \| Promise` | Yes | Platform-supplied conversion: native gas wei → fee-token decimal string. Portal does not perform this conversion. | | `bufferBps` | `number` | No | Basis-point margin on the gas estimate before conversion (e.g. `1000` = +10%). Defaults to `0`. | | `placeholderAmount` | `string` | No | Fee-call amount during the estimation pass. Defaults to `'0.01'`. Must be ≤ the wallet's balance. | `sendBatchedAssets` throws if `estimatedGasCostWei` is `0`. Some chains/providers carry no on-chain fee on the UserOperation (e.g. bundler-level sponsorship that covers all fees). On those chains use `sendBatchUserOp` instead. ## Low-level primitives If you need direct control over the build/sign/broadcast cycle, use the low-level methods: ```typescript theme={null} import { PortalCurve } from '@portal-hq/web' // 1. Build the UserOperation const buildResult = await portal.buildBatchedUserOp({ chain: 'eip155:10143', calls: [ { to: '0xRecipient', value: '1000000000000000', data: '0x' }, ], }) // buildResult.data.userOpHash — 32-byte hex hash to sign // buildResult.data.userOperation — JSON string to broadcast // 2. Sign the hash yourself (strip 0x prefix before passing) const hashToSign = buildResult.data.userOpHash.replace(/^0x/, '') const signature = await portal.rawSign(PortalCurve.SECP256K1, hashToSign) // 3. Broadcast const broadcastResult = await portal.broadcastBatchedUserOp({ chain: 'eip155:10143', userOperation: buildResult.data.userOperation, signature, }) ``` `buildBatchedUserOp` returns `metadata.estimatedGasCostWei` — a build-time upper bound (`totalGas × maxFeePerGas`). This value is `'0'` on chains where the UserOperation carries no on-chain fee. ## Support If you encounter any issues or have questions about batch UserOperations, feel free to reach out to our support team. # Enhance your wallet security with Blockaid Source: https://docs.portalhq.io/sdks/web/guide/blockaid Learn how to integrate real-time transaction, address, token, and URL security scanning using Portal's Web SDK with Blockaid. Portal’s Web SDK integrates with Blockaid to provide real-time security scanning for transactions, addresses, tokens, and URLs. This integration helps detect malicious activity, phishing attempts, scam tokens, and suspicious interactions before users sign or submit transactions. ## Overview The Blockaid integration enables you to: * **Scan transactions** before signing or submission (EVM, Solana, Tron) * **Scan addresses** for known malicious actors or compromised contracts * **Validate tokens** to detect scams or security risks * **Verify URLs** to prevent phishing attacks ## Prerequisites Before using Blockaid security scanning, ensure you have: * A properly initialized Portal client * Blockaid integration enabled in your Portal Dashboard (see [Blockaid Integration](../../../integrations/Security/blockaid)) ## Scanning EVM Transactions Use `scanEVMTx` to analyze Ethereum transactions before signing or broadcasting them. This method scans EVM transactions for malicious contract interactions, risky approvals, phishing attempts, and other on-chain security threats. ```typescript theme={null} async function scanEvmTransaction(portal: Portal) { const response = await portal.security.blockaid.scanEVMTx({ chain: 'eip155:11155111', data: { from: '0x5e1a0d484c5f0de722e82f9dca3a9d5a421d47cb', to: '0x0d524a5b52737c0a02880d5e84f7d20b8d66bfba', data: '0x', value: '0x1000000000000000', }, account_address: '0x5e1a0d484c5f0de722e82f9dca3a9d5a421d47cb', }) console.log('Blockaid EVM scan response:', response) } ``` *** ## Scanning Solana Transactions Use `scanSolanaTx` to analyze Solana transactions before signing. This method detects malicious program invocations, suspicious token movements, and other Solana-specific risks. ```typescript theme={null} async function scanSolanaTransaction(portal: Portal) { const response = await portal.security.blockaid.scanSolanaTx({ encoding: 'base58', chain: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', method: 'signAndSendTransaction', options: ['simulation', 'validation'], account_address: '86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY', transactions: [ 'vxBNpvao9QJmLKXUThbbjRnxm3ufu4Wku97kHd5a67FDjSqeHwcPrBKTjAHp4ECr61eWwoxvUEVTuuWX65P9bCNDJrTJpX64vjdtpHA8cogA4C92Ubj813wUUA8Ey4Bvcrdj5c1bSTCnwoE8HeFYiyioRLNZTpShx8zkyzXaxkpUvPVRN26363bGvJDNSJt8bihmwAPxfrH7kSV9BvAuhRWsiuUAN4GZzyAiptknHZ1xjzrKAHz68UNJpWnYkaUThye6r3iULZUcp7baBaGAtnUmAdDMGG1UpBusWLF', ], }) console.log('Blockaid Solana scan response:', response) } ``` *** ## Scanning Tron Transactions Use `scanTronTx` to analyze Tron transactions before signing. This method detects malicious smart contract interactions, suspicious token movements, and other Tron-specific security risks. `scanTronTx` only supports `tron:mainnet`. Passing any other chain identifier, such as `tron:shasta` or `tron:nile`, throws an error before any request is sent to Blockaid. ```typescript theme={null} async function scanTronTransaction(portal: Portal) { const response = await portal.security.blockaid.scanTronTx({ chain: 'tron:mainnet', account_address: 'TKhJBwsH7hvegxrUHW4oHT5t1BUDds2UG4', data: { from: 'TKhJBwsH7hvegxrUHW4oHT5t1BUDds2UG4', to: 'TMacq4TDUw5q8NFBwmbY4RLXvzvG5JTkvi', data: '0xa9059cbb00000000000000000000004102d8b1afe419aaee6ea2a421913b93bc1d5579', value: '0x0', }, options: ['simulation', 'validation'], }) console.log('Blockaid Tron scan response:', response) } ``` The request object accepts the following fields: | Field | Type | Required | Description | | ----------------- | ---------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `chain` | `string` | Yes | Must be `tron:mainnet`. Any other value throws an error. | | `account_address` | `string` | No | Address initiating the transaction (Base58 format, starts with `T`). Defaults to your Portal client's TRON address when omitted; the request fails if neither is available. | | `data.from` | `string` | No | Sender address (Base58 format). Defaults to the resolved `account_address` when omitted. | | `data.to` | `string` | No | Recipient address (Base58 format) | | `data.data` | `string` | No | Encoded contract call data (hex) | | `data.value` | `string` | No | Amount to transfer, in sun (hex). 1 TRX = 1,000,000 sun. | | `metadata` | `object` | No | Dapp (`{ domain }`) or non-dapp (`{ non_dapp }`) context for the transaction. Only applied as-is when provided — if you're scanning on behalf of a dApp, set `domain` explicitly, since a default domain is used only when `metadata` is omitted entirely. | | `options` | `string[]` | No | Analysis types to run: `'simulation'`, `'validation'`, or both | ### Reading the response Like `scanEVMTx`, the `scanTronTx` result is nested under `data.rawResponse`: ```typescript theme={null} const rawResponse = response.data?.rawResponse if (rawResponse?.validation?.result_type === 'Malicious') { console.error('Transaction flagged as malicious:', rawResponse.validation) return } if (rawResponse?.validation?.result_type === 'Warning') { // Optionally prompt the user to confirm before proceeding } // Safe to proceed with signing ``` `data.rawResponse` can include: | Field | Description | | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `validation` | `result_type` (`Benign`, `Warning`, or `Malicious`), plus `classification`, `description`, `reason`, `status`, and a list of `features` | | `simulation` | Simulated effects of the transaction, returned when `'simulation'` is included in `options` | | `events` | Contract events emitted during simulation, when available | | `gas_estimation` | Estimated gas usage for the transaction, when available | | `features` | Additional Blockaid-detected features for the transaction, when available | | `block` | Block associated with the scan | | `chain` | Echoes back the requested chain (`tron:mainnet`) | | `account_address` | Echoes back the requested `account_address`, when provided | ### Error handling * Passing a `chain` other than `tron:mainnet` rejects immediately with an error and does not send a request to Blockaid. * Requests with an invalid or incomplete payload, or failures from the Blockaid API itself, reject with the error returned by Blockaid. *** ## Scanning Addresses Use `scanAddress` to analyze a single address for known security risks. This method can be used for both EVM and Solana addresses and detects malicious contracts, compromised wallets, sanctioned addresses, and other flagged entities. ### EVM Address Scan ```typescript theme={null} async function scanEvmAddress(portal: Portal) { const response = await portal.security.blockaid.scanAddress({ chain: 'eip155:1', address: '0x31c05d73f2333b5a176cfdbb7c5ef96ec7bb04ac', }) console.log('Blockaid EVM address scan response:', response) } ``` ### Solana Address Scan ```typescript theme={null} async function scanSolanaAddress(portal: Portal) { const response = await portal.security.blockaid.scanAddress({ chain: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', address: 'BBQUMqaFELxTFh8r1xSttYMHX6ZKzLWhsmGod2vuxgEc', }) console.log('Blockaid Solana address scan response:', response) } ``` *** ## Scanning Tokens Use `scanTokens` to analyze multiple tokens in a single request for known security risks. This method detects scam tokens, honeypots, compromised contracts, and other malicious token behavior. ```typescript theme={null} async function scanTokens(portal: Portal) { const response = await portal.security.blockaid.scanTokens({ chain: 'eip155:1', tokens: [ '0x66587563e933bbf3974b89156b47bb82b921eb35', '0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d', ], }) console.log('Blockaid token scan response:', response) } ``` *** ## Scanning URLs Use `scanURL` to detect phishing sites and malicious domains before users navigate to them. This method helps protect users from social engineering attacks and malicious off-chain activity. ```typescript theme={null} async function scanURL(portal: Portal) { const response = await portal.security.blockaid.scanURL({ url: 'https://www.google.com', }) console.log('Blockaid URL scan response:', response) } ``` *** ## Next Steps * Learn about [signing transactions](./sign-a-transaction) * Explore [transaction simulation](./simulate-a-transaction) * Review [Portal API methods](./portal-api-methods) * Check out the [Blockaid Integration setup](../../../integrations/Security/blockaid) # Configure a custom subdomain Source: https://docs.portalhq.io/sdks/web/guide/configure-a-custom-subdomain Run the Portal Web SDK on a subdomain of your application so it works in browsers that block third-party cookies. The Portal Web SDK requires a custom subdomain to work reliably in Safari and other browsers that block third-party cookies. This guide explains why, and walks you through the setup. ## Why you need this When you initialize `Portal`, the SDK creates a hidden iframe served from Portal's servers (`web.portalhq.io` by default). Everything sensitive is scoped to that iframe's origin: the **Client Session Token cookie** that authenticates your user, and the user's **MPC signing share** in LocalStorage. Your application's JavaScript cannot read either, which protects the signing share from XSS bugs in your app. Browsers treat cookies and storage that belong to a different site than the one in the address bar as **third-party**. Safari blocks third-party cookies by default, Firefox partitions them, and Chrome blocks them in Incognito. When they are blocked, the iframe cannot keep its session or its signing share, and wallet operations fail. A custom subdomain fixes this: point `portal.yourdomain.com` at Portal, and the SDK loads the iframe from that subdomain. Same Portal servers, but now first-party in every browser. The iframe is still a different origin than your app, so the signing share stays isolated. | | Default (`web.portalhq.io`) | Custom subdomain (`portal.yourdomain.com`) | | ----------------------------------------- | ----------------------------------------- | ------------------------------------------ | | Cookie and storage context | Third-party, blocked by Safari and others | First-party, works in all browsers | | Signing share isolated from your app's JS | Yes | Yes | ## Setup instructions ### 1. Create the DNS record Create a `CNAME` record in your DNS provider: 1. Set the DNS Record Type to `CNAME`. 2. Set the Name or Host field to `portal` (or any subdomain name you prefer). This creates a subdomain like `portal.yourdomain.com`. 3. Set the Data or Content field to `web.portalhq.io`. Your subdomain **must** be a child of the domain your application runs on. If your application is at `yourdomain.com`, configure `portal.yourdomain.com`. If your application is at `app.yourdomain.com`, configure `portal.app.yourdomain.com`. The browser only treats the iframe as first-party when the subdomain and your application share a site. ### 2. Verify the record Confirm the record resolves before contacting Portal: ```bash theme={null} dig portal.yourdomain.com ``` The answer section should show your subdomain pointing to `web.portalhq.io`: ```text theme={null} ;; ANSWER SECTION: portal.yourdomain.com. 1799 IN CNAME web.portalhq.io. ``` If the CNAME does not appear, wait for DNS propagation (typically minutes, up to a few hours depending on your provider) and check that the record was created on the correct zone. ### 3. Tell Portal your subdomain Reach out to Portal with the subdomain value. Portal updates its backend to accept traffic on your subdomain and issues a TLS certificate for it. Until the certificate is issued, requests to your subdomain fail with a TLS error. This is expected. Certificate issuance completes shortly after your CNAME resolves and Portal finishes the configuration. Portal will confirm when your subdomain is ready. ### 4. Set the `host` in your SDK config Pass your subdomain as the `host` when you initialize Portal. Without this step, the SDK keeps using `web.portalhq.io` and nothing changes. ```typescript theme={null} import Portal from '@portal-hq/web' export const portal = new Portal({ apiKey: 'YOUR-CLIENT-API-KEY', host: 'portal.yourdomain.com', }) ``` ### 5. Verify end to end Open your application in Safari (it blocks third-party cookies by default, so it is the strictest test) and run a wallet operation like generate or sign. If it succeeds in Safari, the subdomain is configured correctly. ## Security in depth Running the Web SDK on a separate subdomain keeps Portal's resources isolated from your main application. **Cookies** The Web SDK uses a cookie to store the `ClientSessionToken`, which authenticates a user to the Portal backend. The cookie is configured with the `http-only` and `secure` flags, so it is only transmitted over TLS and is inaccessible to JavaScript. The cookie is scoped to the subdomain, which means it is **not** included on requests to your application, only on requests to Portal's backend. **LocalStorage** The Web SDK uses LocalStorage to store the user's signing share, which is used during MPC operations to sign messages and transactions. Values in LocalStorage are scoped to the subdomain, so they **cannot** be accessed by your application's JavaScript. This isolation protects the MPC share from XSS bugs or malicious scripts on your web application. ## Troubleshooting * **Works in Chrome but not Safari**: the `host` option is probably not set, so the SDK still loads the iframe from `web.portalhq.io`. Check step 4. * **TLS or certificate error on the subdomain**: the certificate has not been issued yet, or the CNAME changed after issuance. Verify the record with `dig` and confirm the subdomain with Portal. * **`dig` shows no CNAME**: the record has not propagated, or it was created on a different zone than your application's domain. # Create a wallet Source: https://docs.portalhq.io/sdks/web/guide/create-a-wallet The first step to the MPC process is generating key shares, or a wallet, for your users. Once you have an instance of `Portal`, you can add logic to create web3 wallets. ## The onReady hook The `Portal` class dynamically loads a number of resources behind the scenes to enable MPC directly in the browser. For this reason, some functionality is not immediately available after initialization. We have include an `onReady` function to enable you to write your application logic in a more linear manner without having to explicitly poll the `ready` state of your `Portal` instance. ```typescript theme={null} portal.onReady(() => { // Your code }) ``` ## Wallet Creation The `createWallet` function initiates the MPC process to create a set of shares. The user's share and the wallet address is automatically stored in the browser's local storage, but hidden from the main browser context of your web application. Safari's ITP can clear `localStorage` after periods of inactivity, removing the signing share from the device. Use [`onWalletNotOnDevice`](/sdks/web/guide/handle-storage-loss) to detect this and prompt recovery. ```typescript theme={null} portal.onReady(async () => { const walletExists = await portal.doesWalletExist() if (!walletExists) { await portal.createWallet() } const ethAddress = await portal.getEip155Address() const solAddress = await portal.getSolanaAddress() const tronAddress = await portal.getTronAddress() }) ``` Be sure to use a **Production** API key when creating clients for production. Read more details about going to production [here](../../../resources/going-to-production). Want faster wallet creation? Enable the [`usePreGeneratedWallet`](./feature-flags#usepregeneratedwallet) feature flag—no changes to the code above are needed. That's it! You now have an MPC Wallet built into your web application. # Cross-device sessions Source: https://docs.portalhq.io/sdks/web/guide/cross-device-sessions Use wallet provisioning to enable your users to create multiple signing shares across their devices. Once users successfully back up their wallets, they can generate a new set of signing shares irrespective of the SDK in use. Furthermore, both the new and old sets of signing shares can be used simultaneously, thanks to **`portal.provisionWallet`**. This provisioning relies on the user already having a successful set of backup shares. While provisioning, the wallet fetches the encryption key from the user's cloud storage provider to decrypt the encrypted client backup share. Note that our SDK handles the decryption; you only need to supply the encrypted client backup share. For wallet provisioning support, consult [the recovery documentation](./recover-a-wallet) to establish the necessary **`/backup/fetch`** webhook. Also, ensure the user has successfully completed the backup process. Here's how to implement **`portal.provisionWallet`**: ```tsx theme={null} import axios from 'axios' import { BackupMethods } from '@portal-hq/web' const ProvisionButton: FC = () => { const handleProvision = async () => { // Get the existing encrypted client backup share from your API. const { data: backupShare } = await axios.get('/clients/[clientId]/backup') const backupMethod = BackupMethods.gdrive // Use the backupMethod that was used by the client when they backed up their wallet. const backupConfig = {} // Get a new signing share from running provisionWallet. await portal.provisionWallet(backupShare, backupMethod, backupConfig) } return ( ) } export default ProvisionButton ``` And that's it! You're now equipped to support multiple sessions across different devices for your users. # Manage Token Delegations Source: https://docs.portalhq.io/sdks/web/guide/delegations Learn how to approve, revoke, and manage token delegations using Portal's Web SDK. Portal's Web SDK provides token delegation capabilities through the `portal.delegations` API. This enables approving token spending, revoking approvals, checking delegation status, and transferring tokens as a delegate on both EVM and Solana chains. ## Overview The delegations functionality allows you to: * **Approve** other addresses to spend tokens on behalf of your wallet * **Revoke** existing delegations to remove spending permissions * **Check status** of active delegations and balances * **Transfer tokens** as a delegate from another address * **Approve, revoke, and transfer in one step** using the high-level submit helpers (`approveAndSubmit`, `revokeAndSubmit`, `transferAndSubmit`) ## Prerequisites Before using delegation operations, ensure you have: * A properly initialized Portal client * An active wallet with tokens on the target network (see [Create a wallet](./create-a-wallet)) * Understanding of [token delegations concepts](/resources/delegations) Delegations apply to ERC-20 tokens (EVM) and SPL Tokens (Solana) only. Native assets like ETH, MON, and SOL cannot be delegated — they have no on-chain `approve` / `transferFrom` (or SPL delegate) semantics. Calls using a native asset identifier will be rejected. See [Delegations](/resources/delegations#what-are-token-delegations) for the protocol-level reason and workarounds. *** ## High-Level Methods These methods provide auto-submit behavior. For most use cases, these helpers are the recommended starting point. They call the matching delegation API method, then sign and broadcast each returned transaction in order — all in a single call. The low-level methods (`approve`, `revoke`, `transferFrom`) only build delegation transactions via the API. You receive EVM transaction objects or Solana-encoded payloads and must call `portal.request` with the correct method yourself (`eth_sendTransaction` or `sol_signAndSendTransaction`). The high-level methods handle that signing and submission step for you, resolving to transaction hashes only — they do not wait for on-chain confirmation. When you use the standard `Portal` client, `portal.delegations` is automatically wired with a default `signAndSendTransaction` implementation that picks `eth_sendTransaction` vs `sol_signAndSendTransaction` based on whether the chain id **starts with** `solana`. The signer is resolved in priority order: 1. **Per-call override** — `options.signAndSendTransaction` when provided 2. **Instance-level default** — configured on `Portal` (automatic when you construct `Portal`) 3. **Error** — thrown if neither is available: `[Delegations] No signer configured. Call setSignAndSendTransaction() on the instance or pass signAndSendTransaction in options.` TypeScript request and options shapes ([`ApproveDelegationRequest`](/sdks/web/reference#approve-delegation-request), [`RevokeDelegationRequest`](/sdks/web/reference#revoke-delegation-request), [`TransferFromRequest`](/sdks/web/reference#transfer-from-request), [`DelegationSubmitOptions`](/sdks/web/reference#delegation-submit-options), [`DelegationSubmitProgress`](/sdks/web/reference#delegation-submit-progress)) are listed in [Delegations — Web SDK reference](/sdks/web/reference#delegations). Types are exported from `@portal-hq/web`. ### `approveAndSubmit` #### Overview Calls `approve`, then signs and sends every transaction in the response. Use it when you want a single step from approval intent to submitted transactions instead of manually calling `portal.request` after `approve`. #### Example ```typescript theme={null} // Using Portal — no signAndSendTransaction needed (auto-configured) const { hashes } = await portal.delegations.approveAndSubmit( { chain: 'eip155:11155111', token: 'USDC', delegateAddress: '0xb52a818536341003c9d923103abd3659c27e5a2b', amount: '10', }, { onProgress: (event) => console.log(event), }, ) console.log('Submitted:', hashes) ``` #### Parameters | Name | Type | Required | Description | | -------------------------------- | ----------------- | -------- | ------------------------------------------------------------------------------------ | | `params` | `object` | Yes | Same shape as `approve`. | | `params.chain` | `string` | Yes | Chain CAIP ID (EVM `eip155:…` or Solana `solana:…`). | | `params.token` | `string` | Yes | Token symbol for the delegations API. | | `params.delegateAddress` | `string` | Yes | Address allowed to spend on your behalf. | | `params.amount` | `string` | Yes | Approval amount as a positive decimal string. | | `options` | `object` | No | Optional callbacks and configuration. | | `options.signAndSendTransaction` | `function` | No | Per-call signer override. Auto-configured by `Portal` when using the default client. | | `options.onProgress` | `(event) => void` | No | See **Progress events** below. | **Progress events** (when `onProgress` is set): the callback is invoked **twice per transaction** — once with `step: 'signing'` (fields: `index`, `total`) before signing begins, and once with `step: 'submitted'` (fields: `index`, `total`, `hash`) after `signAndSendTransaction` resolves. For a batch of *n* transactions, `onProgress` is called *2n* times in total. #### Returns * **`{ hashes: string[] }`** — One entry per submitted transaction, in the same order as `transactions` (EVM) or `encodedTransactions` (Solana) from the API. Each value is the hash returned by `signAndSendTransaction` for that item. #### Notes / Gotchas * Transactions are submitted **sequentially** (not batched in one RPC call). If the API returns multiple payloads, the second is only sent after the first send resolves. * The method returns as soon as transactions are **submitted**; waiting for confirmations is your app's responsibility. * Each step triggers a **wallet / signing** flow through the Portal provider. * For Solana responses, encoded payloads are used when `transactions` is missing or empty (same selection rules as the implementation). * If the API response includes no `transactions` and no `encodedTransactions`, the method throws: `No transactions in delegation response.` ### `revokeAndSubmit` #### Overview Calls `revoke`, then signs and sends each returned transaction. Use it to remove spending permission and broadcast the revocation in one flow. #### Example ```typescript theme={null} const { hashes } = await portal.delegations.revokeAndSubmit( { chain: 'eip155:11155111', token: 'USDC', delegateAddress: '0xb52a818536341003c9d923103abd3659c27e5a2b', }, { onProgress: (event) => console.log(event), }, ) ``` #### Parameters | Name | Type | Required | Description | | ------------------------ | -------- | -------- | -------------------------------------------------------------------- | | `params` | `object` | Yes | Same shape as `revoke`. | | `params.chain` | `string` | Yes | Chain CAIP ID. | | `params.token` | `string` | Yes | Token symbol. | | `params.delegateAddress` | `string` | Yes | Delegate address whose approval you revoke. | | `options` | `object` | No | Same as `approveAndSubmit` (`signAndSendTransaction`, `onProgress`). | #### Returns * **`{ hashes: string[] }`** — Same semantics as `approveAndSubmit`: ordered hashes from each `signAndSendTransaction` call. #### Notes / Gotchas * Same execution, signing, confirmation, chain-routing, and empty-response behavior as `approveAndSubmit`. ### `transferAndSubmit` #### Overview Calls `transferFrom`, then signs and sends each returned transaction. Use it when your wallet is the delegate moving funds from an owner you were approved for. #### Example ```typescript theme={null} const { hashes } = await portal.delegations.transferAndSubmit( { chain: 'eip155:11155111', token: 'USDC', fromAddress: '0x03c66353df426e18e6e7866fa9e2e73ef6833500', toAddress: '0xdFd8302f44727A6348F702fF7B594f127dE3A902', amount: '0.0001', }, { onProgress: (event) => console.log(event), }, ) ``` #### Parameters | Name | Type | Required | Description | | -------------------- | -------- | -------- | -------------------------------------------------------------------- | | `params` | `object` | Yes | Same shape as `transferFrom`. | | `params.chain` | `string` | Yes | Chain CAIP ID. | | `params.token` | `string` | Yes | Token symbol. | | `params.fromAddress` | `string` | Yes | Owner who delegated to your wallet. | | `params.toAddress` | `string` | Yes | Recipient of the transfer. | | `params.amount` | `string` | Yes | Amount to move. | | `options` | `object` | No | Same as `approveAndSubmit` (`signAndSendTransaction`, `onProgress`). | #### Returns * **`{ hashes: string[] }`** — Same ordering rules as the other submit helpers. #### Notes / Gotchas * Same sequential submit, no confirmation wait, signing, chain routing, and empty-response error as above. * Unlike `transferFrom`, **`transferAndSubmit` does not return API `metadata`** (for example `TransferAsDelegateMetadata`); only `{ hashes }` is exposed after broadcast. ### Overriding the transaction sender When using `Portal`, the default `signAndSendTransaction` is automatically configured, so you don't need to provide it. For custom signing logic (for example a different submission path), pass `signAndSendTransaction` in the options: ```typescript theme={null} await portal.delegations.approveAndSubmit( { chain: 'eip155:11155111', token: 'USDC', delegateAddress: '0xb52a818536341003c9d923103abd3659c27e5a2b', amount: '10', }, { signAndSendTransaction: async (tx, chainId) => { const method = chainId.startsWith('solana') ? 'sol_signAndSendTransaction' : 'eth_sendTransaction' const hash = await portal.request({ chainId, method, params: [tx], }) if (typeof hash !== 'string' || hash.trim().length === 0) { throw new Error('No transaction hash returned from provider') } return hash }, onProgress: (event) => console.log(event), }, ) ``` The payload `tx` is the same shape the default signer passes through: an EVM transaction object from `transactions[]`, or a Solana payload from `encodedTransactions[]`. *** ## Low-level methods The following methods return raw API responses — transaction objects for EVM or base64-encoded payloads for Solana — which you sign and broadcast yourself via `portal.request`. Use these when you need full control over the signing or submission step. ## Approving Delegations Use `approve` to grant another address permission to spend tokens on your behalf. This method works for both EVM and Solana chains. EVM Approval ```typescript theme={null} async function approveEVMDelegation(portal: Portal) { const tx = await portal.delegations.approve({ chain: 'eip155:11155111', // Sepolia testnet token: 'USDC', delegateAddress: '0xb52a818536341003c9d923103abd3659c27e5a2b', amount: '10', }) console.log('Approve response:', tx) // Sign and send the transaction const hash = await portal.request({ chainId: 'eip155:11155111', method: 'eth_sendTransaction', params: [tx.transactions[0]] }) console.log('Transaction hash:', hash) return hash } ``` Solana Approval ```typescript theme={null} async function approveSolanaDelegation(portal: Portal) { const tx = await portal.delegations.approve({ chain: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', // Solana Devnet token: 'USDC', delegateAddress: '7EYg9HUZBoCeCfWbcj3EFNX5Ecgjn9FTY2UhnRny5NYv', amount: '10', }) console.log('Approve response:', tx) // Sign and send the transaction const hash = await portal.request({ chainId: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', method: 'sol_signAndSendTransaction', params: [tx.encodedTransactions[0]] }) console.log('Transaction hash:', hash) return hash } ``` *** ## Checking Delegation Status Use `getStatus` to check current delegations and token balances for a specific delegate address. EVM Status Check ```typescript theme={null} async function getEVMDelegationStatus(portal: Portal) { const status = await portal.delegations.getStatus({ chain: 'eip155:11155111', token: 'USDC', delegateAddress: '0xb52a818536341003c9d923103abd3659c27e5a2b', }) console.log('Delegation status:', status) console.log('Balance:', status.balance) console.log('Delegations:', status.delegations) } ``` Solana Status Check ```typescript theme={null} async function getSolanaDelegationStatus(portal: Portal) { const status = await portal.delegations.getStatus({ chain: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', token: 'USDC', delegateAddress: '7EYg9HUZBoCeCfWbcj3EFNX5Ecgjn9FTY2UhnRny5NYv', }) console.log('Delegation status:', status) } ``` *** ## Revoking Delegations Use `revoke` to remove spending permissions from a delegate address. EVM Revoke ```typescript theme={null} async function revokeEVMDelegation(portal: Portal) { const tx = await portal.delegations.revoke({ chain: 'eip155:11155111', token: 'USDC', delegateAddress: '0xb52a818536341003c9d923103abd3659c27e5a2b', }) console.log('Revoke response:', tx) const hash = await portal.request({ chainId: 'eip155:11155111', method: 'eth_sendTransaction', params: [tx.transactions[0]] }) console.log('Transaction hash:', hash) return hash } ``` Solana Revoke ```typescript theme={null} async function revokeSolanaDelegation(portal: Portal) { const tx = await portal.delegations.revoke({ chain: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', token: 'USDC', delegateAddress: '7EYg9HUZBoCeCfWbcj3EFNX5Ecgjn9FTY2UhnRny5NYv', }) console.log('Revoke response:', tx) const hash = await portal.request({ chainId: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', method: 'sol_signAndSendTransaction', params: [tx.encodedTransactions[0]] }) console.log('Transaction hash:', hash) return hash } ``` Always revoke unused delegations after completing operations to minimize security risks. *** ## Transferring as a Delegate Use `transferFrom` to transfer tokens from another address that has delegated spending permission to you. EVM Transfer From ```typescript theme={null} async function transferFromEVM(portal: Portal) { const tx = await portal.delegations.transferFrom({ chain: 'eip155:11155111', token: 'USDC', fromAddress: '0x03c66353df426e18e6e7866fa9e2e73ef6833500', // Token owner toAddress: '0xdFd8302f44727A6348F702fF7B594f127dE3A902', // Recipient amount: '0.0001', }) console.log('TransferFrom response:', tx) const hash = await portal.request({ chainId: 'eip155:11155111', method: 'eth_sendTransaction', params: [tx.transactions[0]] }) console.log('Transaction hash:', hash) return hash } ``` Solana Transfer From ```typescript theme={null} async function transferFromSolana(portal: Portal) { const tx = await portal.delegations.transferFrom({ chain: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', token: 'USDC', fromAddress: 'GoFBWzCVxSEGYxgSAmdm2itS3EUbYmLpgzEcQ4J3WnsN', // Token owner toAddress: 'GPsPXxoQA51aTJJkNHtFDFYui5hN5UxcFPnheJEHa5Du', // Recipient amount: '0.0001', }) console.log('TransferFrom response:', tx) const hash = await portal.request({ chainId: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', method: 'sol_signAndSendTransaction', params: [tx.encodedTransactions[0]] }) console.log('Transaction hash:', hash) return hash } ``` **Delegation Roles**: `fromAddress` is the token owner who approved the delegation. Your wallet (the delegate) signs the transaction to transfer tokens from the owner to the `toAddress` recipient. *** ## Supported Networks Delegations work on all Portal-supported EVM and Solana chains: * **EVM**: Ethereum, Polygon, Base, Arbitrum, Optimism, Monad, and all other EVM-compatible chains * **Solana**: Solana Mainnet and Devnet For a complete list, see [Blockchain Support](/resources/blockchain-support). *** ## Next Steps * Learn about [signing transactions](./sign-a-transaction) * Explore [Portal API methods](./portal-api-methods) * Review [delegation concepts](/resources/delegations) * Check out [wallet lifecycle management](./manage-wallet-lifecycle-states) # Eject a wallet Source: https://docs.portalhq.io/sdks/web/guide/eject-a-wallet The eject feature allows a user to construct a private key that can be imported into another wallet manager, such as MetaMask. **Warning:** Providing the custodian backup share to the client device puts both MPC shares on a single device, removing the multi-party security benefits of MPC. This operation should only be done for users who want to move off of MPC and into a single private key. **Use `portal.eject()` at your own risk!** ## Ejecting Your User's Wallets ### With Portal-managed backups To eject the private keys for your users' wallets, their two matching backup shares need to be combined. Since both of these shares are stored on the Portal backend encrypted at rest, we first need to verify that the upcoming request to eject the wallet is not an attack. In order to do this, we make this a two-step process: 1. Make a Custodian-authed `POST` request using your Custodian API Key (the same API key you use to create new users) to the `/api/v3/custodians/me/clients/${clientId}/prepare-eject` endpoint to allow for this client to perform an `eject` operation 2. Call the `portal.eject()` function from your application #### Making the \`prepare-eject\` request This endpoint requires a `walletId` in the `POST` body. This can be found in the `wallets` property of the `/api/custodians/me/clients/${clientId}` `GET` request. If you are planning on ejecting multiple wallets for a user, you'll need to make multiple separate requests to "unlock" all required wallets. ```typescript theme={null} const response = await axios.post( `https://api.portalhq.io/api/v3/custodians/me/clients/${clientId}/prepare-eject`, { walletId: 'YOUR_CLIENTS_WALLET_ID' }, { headers: { 'Authorization': `Bearer ${YOUR_CUSTODIAN_API_KEY}` } }, ) ``` #### Ejecting the wallets ```typescript theme={null} // Provide the appropriate backup method for your client's wallet const privateKeys = await portal.eject(BackupMethods.password, { passwordStorage: { password, }, }) ``` This function will perform the combining of shares from our server and the cryptographic operation required to build a private key for the wallet. The return value is the string literal of the private key. ### With Backups from your server To eject the private keys for your users' wallets, their two matching backup shares need to be combined. This requires providing the following two shares to `portal.ejectPrivateKeys()`: * **User Backup Share** - Encrypted backup share received from `portal.backup()`. * **Custodian Backup Shares** - Raw backup shares received from the `POST /backup` webhook (one for each curve). Note that a user can have multiple wallets (unique by curve). This means when the user calls `portal.ejectPrivateKeys()`, they can receive multiple private keys. Here's an example implementation: ```typescript theme={null} import { BackupMethods } from '@portal-hq/web' import { BackupConfigs } from '@portal-hq/web/types' // Create a backup config for portal.ejectPrivateKeys(). const backupConfig: BackupConfigs = { passwordStorage: { password: 'USERS_PASSWORD', } } // Fetch the user's encrypted backup share from your API. const userBackupShare = await fetchUserBackupShare(userId) // Fetch the custodian backup shares for each curve from your API. const secp256k1CustodianShare = await fetchCustodianBackupShare(userId, 'SECP256K1') const ed25519CustodianShare = await fetchCustodianBackupShare(userId, 'ED25519') // Obtain the private keys for the user's various wallets. const { SECP256K1, ED25519 } = await portal.ejectPrivateKeys( BackupMethods.password, backupConfig, { SECP256K1: secp256k1CustodianShare, ED25519: ed25519CustodianShare }, userBackupShare, ) ``` **`SECP256K1`** is the curve used by Ethereum. **`ED25519`** is the curve used by Solana. You can learn more about signing algorithms and curves [here](http://ethanfast.com/top-crypto.html). And that's it! Now your users can obtain a private key to import their wallet into another wallet manager. # Upgrading to EIP-7702 Source: https://docs.portalhq.io/sdks/web/guide/evm-account-type Learn how to check wallet account type and upgrade EOA wallets to EIP-7702 using Portal's Web SDK. Portal's Web SDK provides EVM Account Type management through the `portal.evmAccountType` API. This enables checking wallet account types and upgrading EOA wallets to EIP-7702 on supported EVM chains. ## Overview The EVM Account Type functionality allows you to: * **Check wallet status** to determine the current account type * **Get addresses** to retrieve the EOA and smart contract addresses for a chain * **Upgrade to EIP-7702** to enable ejecting the Portal wallet's EOA private key ## Prerequisites Before using EVM Account Type operations, ensure you have: * A properly initialized Portal client * An active wallet on a supported network (see [Create a wallet](./create-a-wallet)) * Understanding of [EVM Account Type concepts](/resources/evmAccountType) * You fund the [Upgrade Sponsor address with native tokens for the target chain in the Portal Dashboard](https://app.portalhq.io/upgrade-sponsorship) ## Checking Wallet Status Use `getStatus` to check the current account type of your wallet. The response includes both the account status and metadata about the wallet addresses. ```typescript theme={null} const handleGetStatus = async () => { try { const { data, metadata } = await portal.evmAccountType.getStatus({ chain: 'eip155:10143', // Monad Testnet }) if (data.status === 'EIP_155_EOA') { console.log('✅ Can upgrade to EIP-7702') } else if (data.status === 'EIP_7702_EOA') { console.log('ℹ️ Already upgraded') } else { console.log('❌ Cannot upgrade') } } catch (e) { console.error('Failed to get account status', e) } } ``` **Response (`GetStatusResponse`):** * `data.status`: One of `EIP_155_EOA`, `SMART_CONTRACT`, or `EIP_7702_EOA` * `metadata.eoaAddress`: The EOA address associated with the wallet * `metadata.smartContractAddress`: Your smart contract wallet address (optional, may be `null`/`undefined` for EOA-only accounts) * `metadata.chainId`: The chain identifier *** ## Getting EOA and Smart Contract Addresses Use `getAddresses` to retrieve the EOA and smart contract addresses for your wallet on a specific chain. This method is useful when you only need the addresses without the full status information. ```typescript theme={null} const handleGetAddresses = async () => { try { const result = await portal.evmAccountType.getAddresses({ chain: 'eip155:10143', // Monad Testnet, or eip155:1 for mainnet }) console.log('EOA address:', result.eoaAddress) console.log('Smart contract address:', result.smartContractAddress) } catch (e) { console.error('Failed to get addresses', e) } } ``` **Response (`GetAddressesResponse`):** * `eoaAddress`: The EOA address for the wallet on this chain * `smartContractAddress`: The smart contract wallet address (if any) *** ## Upgrading to EIP-7702 Use `upgradeTo7702` to upgrade an EOA wallet to EIP-7702. This method automatically handles the entire upgrade process, including gas subsidization, and returns the transaction hash. ```typescript theme={null} const handleUpgradeTo7702 = async () => { try { const txHash = await portal.evmAccountType.upgradeTo7702({ chain: 'eip155:10143', // Monad Testnet }) console.log('Upgrade submitted! TX hash:', txHash) const status = await portal.evmAccountType.getStatus({ chain: 'eip155:10143' }) console.log('Final Status:', status.data.status) if (status.data.status === 'EIP_7702_EOA') { console.log('Successfully upgraded to EIP-7702!') } } catch (e) { console.error('Upgrade failed', e) } } ``` **What happens during the upgrade:** 1. **Chain validation**: Verifies the chain uses the `eip155` namespace 2. **Status check**: Confirms the wallet is currently an `EIP_155_EOA` 3. **Authorization building**: Creates the EIP-7702 authorization list 4. **Signature**: Signs the authorization hash with your wallet 5. **Transaction submission**: Submits the subsidized upgrade transaction (gas is paid by Portal) 6. **Transaction hash**: Returns the on-chain transaction hash **Response:** The method returns a `string` containing the transaction hash of the submitted upgrade transaction. *** The upgrade process is irreversible per wallet. Once upgraded to EIP-7702, the wallet cannot be reverted to a standard EOA on that chain. *** ## Supported Networks EVM Account Type upgrades are currently available on: * **Monad Testnet** (`eip155:10143`) For a complete list of supported chains, reach out to the Portal team. *** ## Next Steps * Learn about [signing transactions](./sign-a-transaction) * Explore [Portal API methods](./portal-api-methods) * Review [EVM Account Type concepts](/resources/evmAccountType) * Check out [wallet lifecycle management](./manage-wallet-lifecycle-states) # Feature flags Source: https://docs.portalhq.io/sdks/web/guide/feature-flags Enable or disable specific SDK behaviors using feature flags when initializing Portal. ## Overview Feature flags are set on the `featureFlags` option when you create a `Portal` instance. They control optional behavior such as signing performance and backup options. You do not need to change your signing or wallet code when enabling flags—the SDK adapts automatically. ## Enabling feature flags Pass `featureFlags` in the Portal constructor: ```typescript theme={null} import Portal from '@portal-hq/web' const portal = new Portal({ rpcConfig: { 'eip155:1': 'https://mainnet.infura.io/v3/YOUR_KEY', }, apiKey: 'YOUR_PORTAL_CLIENT_API_KEY', featureFlags: { usePresignatures: true, }, }) ``` ## Available flags ### `usePresignatures` * **Type:** `boolean` * **Default:** `false` * **Description:** When enabled, the SDK uses **presignatures** to improve signing latency. You do not call any presignature APIs yourself; the SDK generates presignatures in the background and uses them automatically when you sign (e.g. via `portal.request()` or `portal.rawSign()`). **What presignatures do** Presignatures pre-compute part of the MPC signing protocol ahead of time. When the user triggers a sign (EVM transaction or raw sign), the SDK consumes one presignature if available and completes the signature faster. If no presignature is available, the SDK falls back to normal signing. Behavior is transparent to your app. **When to enable** Enable `usePresignatures` when you want lower latency on EVM signing without changing your integration. The SDK fills a small buffer after wallet creation or recovery and replenishes it as presignatures are used. **Limitations** * Applies only to **SECP256K1** (EVM) signing. Solana (ED25519) signing is unchanged. * Presignatures are single-use; the SDK manages creation and consumption for you. **Example** ```typescript theme={null} const portal = new Portal({ rpcConfig: { 'eip155:1': 'https://...' }, apiKey: 'YOUR_API_KEY', featureFlags: { usePresignatures: true, }, }) // Signing uses presignatures automatically when available const signature = await portal.request({ chainId: 'eip155:1', method: 'personal_sign', params: [message, address], }) ``` *** ### `usePreGeneratedWallet` * **Type:** `boolean` * **Default:** `false` * **Description:** When enabled, `portal.createWallet()` attempts to claim a pre-generated wallet share instead of running the standard interactive MPC generation. This can make wallet creation faster. You do not need to change how you call `createWallet`. **What it does** Normally, `createWallet` runs the interactive MPC key generation protocol in real time. With this flag enabled, the SDK first tries to claim a share from a pre-computed pool. If that fails with an HTTP 5xx response from the enclave, the SDK automatically falls back to the standard generation flow—the fallback is transparent, and `createWallet` resolves or rejects exactly as it would through the standard flow. Failures that aren't a 5xx (for example, a malformed request or a network failure) are not retried and propagate as usual. **When to enable** Enable `usePreGeneratedWallet` when you want faster wallet creation without changing your integration. The resulting wallet is identical to one created through the standard flow. **Limitations** * This is a performance optimization only; it doesn't change the API surface, the resulting wallet, or how backup/recovery works. **Example** ```typescript theme={null} const portal = new Portal({ rpcConfig: { 'eip155:1': 'https://...' }, apiKey: 'YOUR_API_KEY', featureFlags: { usePreGeneratedWallet: true, }, }) ``` *** ## Reference For all constructor options and the `featureFlags` property, see the [Web SDK Reference](/sdks/web/reference#feature-flags). # Getting Started Source: https://docs.portalhq.io/sdks/web/guide/getting-started Follow this guide to integrate Portal in your web app. Portal provides MPC **wallets** and dApp **connections** for organizations and their users.\ \ To integrate Portal, an organization adds a **client library** to their web app, configures a `CNAME` record, and adds a few **server API endpoints**. ## Basic setup The basic Portal setup consists of a single packages: * `@portal-hq/web` - The core Portal library This allows you to initialize `Portal` in your app. We recommend only initializing one Portal instance per user. ### Authentication Follow this guide to gather all of the credentials you need to [Authenticate to Portal](../../../resources/authentication-and-api-keys). ## Installation
```bash theme={null} yarn add @portal-hq/web ``` ```bash theme={null} npm install --save @portal-hq/web ``` ### Update TSConfig The Web SDK uses the [TSConfig `lib` configuration](https://www.typescriptlang.org/tsconfig#lib) to properly type interaction with the DOM. Update your TSConfig file to include this: ``` // tsconfig.json { "compilerOptions": { "lib": ["es2022", "dom"], ... }, ... } ``` ### Initializing Portal When `rpcConfig` is omitted, the SDK automatically generates gateway URLs for 13 built-in chains. The gateway host defaults to `web.portalhq.io`, and derives from `host` (or `gatewayHost`) when you set a custom subdomain. No third-party RPC provider setup is required. ```typescript theme={null} import Portal from '@portal-hq/web' // Minimal setup — Portal gateway handles RPC for 13 built-in chains automatically. export const portal = new Portal({ apiKey: 'YOUR-CLIENT-API-KEY', host: 'YOUR-CUSTOM-SUBDOMAIN', // Set this once you've defined your custom subdomain // logLevel: 'debug', // optional: 'none' (default), 'error', 'warn', 'info', or 'debug' // logger: console, // optional: custom ILogger // featureFlags: { usePreGeneratedWallet: true }, // optional: faster wallet creation }) ``` For faster wallet generation, enable the [`usePreGeneratedWallet`](./feature-flags#usepregeneratedwallet) feature flag here—no other code changes are needed. If your app needs chains beyond the 13 built-in ones (e.g. Tron, AVAX Fuji), use `buildDefaultRpcConfig` to start from the default set and extend it: ```typescript theme={null} import Portal, { buildDefaultRpcConfig } from '@portal-hq/web' export const portal = new Portal({ apiKey: 'YOUR-CLIENT-API-KEY', host: 'YOUR-CUSTOM-SUBDOMAIN', rpcConfig: { ...buildDefaultRpcConfig('YOUR-CUSTOM-SUBDOMAIN'), 'tron:mainnet': 'https://YOUR-CUSTOM-SUBDOMAIN/rpc/v1/tron/mainnet', }, }) ``` The 13 built-in chains covered by the default gateway config are: Ethereum (`eip155:1`), Sepolia (`eip155:11155111`), Polygon (`eip155:137`), Polygon Amoy (`eip155:80002`), Base (`eip155:8453`), Base Sepolia (`eip155:84532`), Monad (`eip155:143`), Monad Testnet (`eip155:10143`), Optimism (`eip155:10`), Arbitrum One (`eip155:42161`), Avalanche C-Chain (`eip155:43114`), Solana Mainnet (`solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`), and Solana Devnet (`solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`). Polygon Mumbai (`eip155:80001`) has been removed — use Polygon Amoy (`eip155:80002`) instead. We recommend only initializing one Portal instance per user. Do **not** modify **`portal.apiKey`** directly. You can enable logging for debugging and monitoring by setting the `logLevel` parameter. For full logging configuration, including custom loggers and runtime level changes, see [Logging Configuration](./logging). If you are using [Client Session Tokens (CSTs)](../../../resources/authentication-and-api-keys), this hint is for you. When your user's CST expires, all Portal SDKs will throw an error on the next MPC Operation the user makes (e.g. creating a wallet, backing up a wallet, recovering a wallet, or signing). That error will include a code **`SESSION_EXPIRED`** in the SDK methods, which you can use as an indicator to refresh your CST. ### Custom Subdomain The Web SDK runs in a hidden iframe, and the browser scopes its session cookie and the user's signing share to that iframe's origin. In browsers that block third-party cookies (Safari does by default), that storage is blocked unless the iframe runs on a subdomain of your application. Follow the instructions in our [Configure a custom subdomain](./configure-a-custom-subdomain) guide to complete setup for the Web SDK! ### Web SDK Architecture The Web SDK leverages our [Enclave MPC API](../../../apis/enclave-mpc/guide/getting-started) for efficient MPC operation performance. Utilizing confidential computing using [AWS Nitro Enclaves](https://docs.aws.amazon.com/enclaves/latest/user/nitro-enclave.html), the Enclave MPC API provides the same MPC client-server distribution of responsibilities with the speed and efficiency of server side compute. # Handle storage loss Source: https://docs.portalhq.io/sdks/web/guide/handle-storage-loss Detect and respond when a user's wallet signing share is no longer in device storage. Safari's Intelligent Tracking Prevention (ITP) can clear `localStorage` after periods of inactivity. Because Portal stores the user's signing share in `localStorage`, the share may be missing the next time a user opens your app. The SDK invokes any `onWalletNotOnDevice` listeners during initialization when it detects this has happened. ## Detecting storage loss Register your listener as early as possible. If the event already fired before you subscribed, the callback is replayed automatically. ```typescript theme={null} portal.onWalletNotOnDevice(async (payload) => { if (payload.isBackedUp) { // Guide the user through wallet recovery } else { // No backup exists — clear state and restart onboarding await portal.clearLocalWallet() } }) ``` The callback receives a `WalletNotOnDevicePayload`: | Field | Type | Description | | ------------ | ------------------- | --------------------------------------------------- | | `clientId` | `string` | The client ID of the affected wallet | | `isBackedUp` | `boolean` | Whether a backup exists that can be used to recover | | `reason` | `'storage_cleared'` | Why the wallet is not on device | * **`isBackedUp: true`** — direct the user to [recover their wallet](/sdks/web/guide/recover-a-wallet). * **`isBackedUp: false`** — call `clearLocalWallet()` and restart the wallet creation flow. ## Next steps * [Recover a wallet](/sdks/web/guide/recover-a-wallet) — step-by-step recovery flow to direct users to when `isBackedUp` is `true`. * [Create a wallet](/sdks/web/guide/create-a-wallet) — restart onboarding when no backup exists. # Security Scanning with Hypernative Source: https://docs.portalhq.io/sdks/web/guide/hypernative Learn how to integrate real-time security scanning for transactions, addresses, tokens, NFTs, and URLs using Portal's Web SDK with Hypernative. Portal's Web SDK provides comprehensive security scanning capabilities through the `portal.security.hypernative` API. This integration allows you to detect malicious transactions, flagged addresses, compromised tokens, and suspicious URLs before users interact with them. ## Overview The Hypernative integration enables you to: * **Scan transactions** before signing or submission (EVM, EIP-712, Solana) * **Scan addresses** for known malicious actors or compromised contracts * **Validate tokens** to detect scams, honeypots, or security risks * **Check NFTs** for fraudulent collections or suspicious activity * **Verify URLs** to prevent phishing attacks ## Prerequisites Before using Hypernative security scanning, ensure you have: * A properly initialized Portal client * Hypernative integration enabled in your Portal Dashboard (see [Hypernative Integration](../../../integrations/Security/hypernative)) ## Scanning EVM Transactions Use `scanEvmTx` to analyze standard Ethereum transactions before signing or sending. This method scans EVM transactions for malicious contract interactions, suspicious token approvals, and other security risks. EVM transactions are standard Ethereum transactions that include a chain ID to prevent replay attacks across different networks. ```typescript theme={null} async function scanEvmTransaction(portal: Portal) { const response = await portal.security.hypernative.scanEvmTx({ transaction: { fromAddress: '0x7C0...', toAddress: '0xA0b8...', chain: 'eip155:1', input: '0x095ea7b3...', // input data of the transaction value: 0, nonce: 2340, gas: 3000000, gasPrice: 3000000, }, }); console.log('Hypernative EVM scan response:', response); } ``` *** ## Scanning EIP-712 Typed Messages Use `scanEip712Tx` to analyze typed structured data before signing. This method is critical for detecting malicious permit signatures, phishing attempts, and unauthorized token approvals. EIP-712 defines a standard for hashing and signing typed structured data, commonly used for gasless approvals and off-chain signatures. ```typescript theme={null} async function scanEip712Message(portal: Portal) { const response = await portal.security.hypernative.scanEip712Tx({ walletAddress: '0x7b13...', chainId: 'eip155:1', eip712Message: { primaryType: 'Permit', types: { EIP712Domain: [ { name: 'name', type: 'string' }, { name: 'version', type: 'string' }, { name: 'chainId', type: 'uint256' }, { name: 'verifyingContract', type: 'address' }, ], Permit: [ { name: 'owner', type: 'address' }, { name: 'spender', type: 'address' }, { name: 'value', type: 'uint256' }, { name: 'nonce', type: 'uint256' }, { name: 'deadline', type: 'uint256' }, ], }, domain: { chainId: 'eip155:1', name: 'MyToken', version: '1', verifyingContract: '0xa0b8...', }, message: { owner: '0x7b13...', spender: '0x67be...', value: 3000, nonce: 0, deadline: 50000000000, }, }, }); console.log('Hypernative EIP-712 scan response:', response); } ``` *** ## Scanning Solana Transactions Use `scanSolanaTx` to analyze Solana transactions before signing. This method detects malicious program invocations, suspicious token transfers, and other Solana-specific security risks. ```typescript theme={null} async function scanSolanaTransaction(portal: Portal) { const response = await portal.security.hypernative.scanSolanaTx({ showFullFindings: true, transaction: { rawTransaction: 'AQAAAAA....', // Solana transaction string version: '0' as const, }, }); console.log('Hypernative Solana scan response:', response); } ``` *** ## Scanning Addresses Use `scanAddresses` to check multiple addresses for known security risks. This method identifies malicious contracts, compromised wallets, sanctioned addresses, and other flagged entities. ```typescript theme={null} async function scanAddresses(portal: Portal) { const response = await portal.security.hypernative.scanAddresses([ '0x31c0...', '0x2753...', ]); console.log('Hypernative address scan response:', response); } ``` *** ## Scanning NFTs Use `scanNFTs` to validate NFT collections before displaying or allowing interactions. This method detects fraudulent collections, compromised contracts, and suspicious NFT activity. ```typescript theme={null} async function scanNFTs(portal: Portal) { const response = await portal.security.hypernative.scanNFTs([ { address: '0x5C1B...', evmChainId: 'eip155:1', }, { address: '0xC2e0...', evmChainId: 'eip155:1', }, ]); console.log('Hypernative NFT scan response:', response); } ``` *** ## Scanning Tokens Use `scanTokens` to validate ERC-20 tokens before allowing swaps, transfers, or approvals. This method detects honeypots, scam tokens, and compromised token contracts. ```typescript theme={null} async function scanTokens(portal: Portal) { const response = await portal.security.hypernative.scanTokens([ { address: '0xA0b8...', evmChainId: 'eip155:1', }, ]); console.log('Hypernative token scan response:', response); } ``` *** ## Scanning URLs Use `scanURL` to detect phishing sites and malicious domains before users navigate to them. This method is critical for protecting users from social engineering attacks. ```typescript theme={null} async function scanURL(portal: Portal, url: string) { const response = await portal.security.hypernative.scanURL(url); console.log('Hypernative URL scan response:', response); } ``` *** ## Next Steps * Learn about [signing transactions](./sign-a-transaction) * Explore [transaction simulation](./simulate-a-transaction) * Review [Portal API methods](./portal-api-methods) * Check out the [Hypernative Integration setup](../../../integrations/Security/hypernative) # Bridge & Swap with Li.Fi Source: https://docs.portalhq.io/sdks/web/guide/lifi Learn how to bridge and swap tokens across multiple chains using Portal's Web SDK with Li.Fi integration. Portal's Web SDK provides comprehensive cross-chain bridging and swapping capabilities through the `portal.trading.lifi` API. This guide covers high-level end-to-end trades, getting quotes, finding routes, executing swaps and bridges manually, and tracking transaction status. ## Overview The Li.Fi functionality allows you to: * **Run an end-to-end trade** with `tradeAsset` (routes, steps, signing, confirmation, and Li.Fi status polling) * **Get quotes** for bridging or swapping tokens across chains * **Find routes** to discover the best paths for your cross-chain transfers * **Execute swaps and bridges** by signing and submitting transactions * **Track transaction status** for cross-chain transfers * **Poll Li.Fi status** with `pollStatus` when you already have a transaction hash ## Prerequisites Before using Li.Fi operations, ensure you have: * A properly initialized Portal client * An active wallet with the required token(s) on the source network (see [Create a wallet](./create-a-wallet)) * Li.Fi integration enabled in your Portal Dashboard (see [Li.Fi Integration](../../../integrations/Trading/lifi)) **Supported Chains:** Li.Fi integration currently supports EVM-compatible chains (Ethereum, Base, Arbitrum, Polygon, etc.) and Solana. Other non-EVM chains may not be supported for Li.Fi operations. **Solana vs `evmRequestFn`:** The built-in fallback that watches EVM receipts (`evmRequestFn` + receipt polling) only runs when the step’s network is an **`eip155:*`** chain. For **Solana** (or any non-EVM) step, that path does not apply — you must have **`waitForConfirmation`** (the default when using the standard `Portal` client is enough). If you use `LiFi` without Portal’s defaults, supply `waitForConfirmation` yourself for those steps; relying on `evmRequestFn` alone will throw once a non-EVM step needs confirmation. ## High-Level Methods ### `tradeAsset` Runs the end-to-end Li.Fi flow in one call: 1. Discover routes (`getRoutes`) 2. Select a route (`routeIndex`, default `0`) 3. Build each step (`getRouteStep`) 4. Sign and broadcast each step transaction 5. Wait for on-chain confirmation for that step 6. Poll Li.Fi status until terminal for that step 7. Continue to the next step Steps are executed **sequentially** (in order), not in parallel. #### Strict Confirmation Contract Li.Fi enforces a **strict confirmation contract**. Each step MUST be confirmed on-chain before proceeding to the next step: * **Success**: `waitForConfirmation` returns `true` or `void` * **Failure**: Any other value (`false`, `undefined`), timeout, unsupported network, or thrown error When confirmation fails, the entire `tradeAsset` call throws and **no further steps are executed**. This ensures no silent failures or partial execution. **Default timeout behavior:** * Timeout: `900_000ms` (15 minutes) * Poll interval: `4_000ms` * Unsupported networks return `false` (treated as failure) There is no optimistic fallback. ### Signature ```typescript theme={null} tradeAsset( params: LifiTradeAssetParams, options?: LifiTradeAssetOptions, ): Promise ``` **Essential parameters** | Name | Required | Description | | -------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `fromChain` | Yes | Source chain, CAIP-2 (e.g. `'eip155:8453'`). | | `toChain` | Yes | Destination chain, CAIP-2. | | `fromToken` | Yes | Source token symbol or address. | | `toToken` | Yes | Destination token symbol or address. | | `amount` | Yes | Amount in smallest units (e.g. wei). | | `fromAddress` | Yes | Sender address. | | `toAddress` | No | Recipient; defaults to `fromAddress`. | | `routeOptions` | No | Slippage, ordering, bridges, etc. See [Route options](#route-options). | | `routeIndex` | No | Route index from discovery; default `0`. | | `onProgress` | No | Fired for each major stage (`fetching_routes`, `signing`, `submitted`, `confirming`, `lifi_pending`, `complete`, `failed`, …). | | `statusPoll` | No | Overrides the **built-in Li.Fi status polling** used **after** on-chain confirmation for each step (same shape as [`pollStatus` options](#pollstatus-options)). | Second argument `LifiTradeAssetOptions`: | Name | Required | Description | | ------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `signAndSendTransaction` | No | Per-call signer override for route-step transactions. | | `waitForConfirmation` | No | Per-call confirmation override. Called after each submitted step with `(txHash, network)`. **MUST return `true` or resolve for success.** Returning `false`, throwing an error, or timing out will **abort the entire flow** and throw an error. When omitted, the built-in EVM receipt poller is used with the same strict behavior. | | `evmRequestFn` | No | **Web SDK:** Per-call EVM RPC function used by the built-in fallback EVM receipt poller when `waitForConfirmation` is not provided. | | `evmPollerOptions` | No | Per-call tuning for the built-in EVM receipt poller (`pollIntervalMs`, `timeoutMs`) when `waitForConfirmation` is not provided. | `tradeAsset` requires **at least one** confirmation mechanism. If neither `waitForConfirmation` nor `evmRequestFn` is available (from instance defaults or per-call options), the method throws immediately: `[LiFi] tradeAsset requires waitForConfirmation or evmRequestFn fallback.` When you construct `Portal` with an `rpcConfig`, `evmRequestFn` is wired automatically — no extra setup needed. This requirement only surfaces when using `LiFi` standalone or without Portal's default wiring. On **non-EVM** networks (for example **Solana**), `evmRequestFn` is never used for confirmation — only **`waitForConfirmation`** applies. Use the standard `Portal` instance (which sets both the signer and `waitForConfirmation`), or pass `waitForConfirmation` in options for those routes. Receipt-polling timeouts are **strict**: if confirmation times out, the entire `tradeAsset` call throws and does not continue to bridge-status polling or subsequent steps. **Return value** | Field | Description | | -------- | ------------------------------------- | | `hashes` | Transaction hashes per executed step. | | `steps` | Step objects from the API. | | `route` | The executed route. | **Example (progress + per-call confirmation options)** ```typescript theme={null} import Portal from '@portal-hq/web' import type { LifiTradeAssetParams, LifiTradeAssetOptions } from '@portal-hq/web' const portal = new Portal({ apiKey: 'YOUR_PORTAL_CLIENT_API_KEY', rpcConfig: { 'eip155:8453': 'https://YOUR_RPC_URL', 'eip155:42161': 'https://YOUR_RPC_URL', }, }) async function runTrade() { const fromAddress = await portal.getEip155Address() if (!fromAddress) throw new Error('No EVM address') const params: LifiTradeAssetParams = { fromChain: 'eip155:8453', toChain: 'eip155:42161', fromToken: 'ETH', toToken: 'USDC', amount: '1000000000000', fromAddress, statusPoll: { everyMs: 10_000, initialDelayMs: 10_000, timeoutMs: 600_000, }, onProgress: (status, data) => { console.log('[Li.Fi]', status, data?.txHash ?? '') }, } const options: LifiTradeAssetOptions = { waitForConfirmation: async (txHash, network) => { return portal.waitForConfirmation(txHash, network) }, evmPollerOptions: { pollIntervalMs: 4_000, timeoutMs: 600_000 }, } try { const result = await portal.trading.lifi.tradeAsset(params, options) console.log('Hashes:', result.hashes) console.log('Executed steps:', result.steps.length) console.log('Route id:', result.route.id) return result } catch (e) { console.error('tradeAsset failed', e) throw e } } ``` **Example (default confirmation behavior)** When you construct `Portal`, Li.Fi receives the same default `signAndSendTransaction`, `waitForConfirmation`, and `evmRequestFn` as other trading helpers — you can call `tradeAsset` with only `params` if that wiring is sufficient for your chain and RPC setup. ```typescript theme={null} const result = await portal.trading.lifi.tradeAsset({ fromChain: 'eip155:8453', toChain: 'eip155:42161', fromToken: 'ETH', toToken: 'USDC', amount: '1000000000000', fromAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', }) ``` Wrap `tradeAsset` in `try/catch`; failures (no route, step error, receipt timeout, Li.Fi terminal `FAILED`, …) throw after `onProgress` may report `'failed'`. ### Progress lifecycle `onProgress` can emit statuses such as: * `fetching_routes` * `route_selected` * `preparing_step` * `signing` * `submitted` * `confirming` * `lifi_pending` * `step_done` * `complete` * `failed` ### `pollStatus` Built-in Li.Fi status polling with retries and backoff. Use when you already have a **tx hash** (for example from a manual flow) and want the same polling behavior as inside `tradeAsset`, without implementing the loop yourself. On Web, pass the update callback as **`onUpdate` inside the second-argument options object** (React Native passes it as a separate argument). ```typescript theme={null} import Portal from '@portal-hq/web' async function pollTransferStatus(portal: Portal, txHash: string) { const terminal = await portal.trading.lifi.pollStatus( { txHash, fromChain: 'eip155:8453', toChain: 'eip155:42161', }, { onUpdate: (statusUpdate) => { console.log('Status:', statusUpdate.status) return true }, everyMs: 10_000, timeoutMs: 600_000, }, ) console.log('Final status:', terminal.status) } ``` ### `pollStatus` options | Name | Default | Description | | ---------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `everyMs` | `10000` | Time between requests (ms). | | `initialDelayMs` | `10000` | Delay before the first request (ms). Same default when `tradeAsset` forwards `statusPoll`: omitted fields keep these defaults unless you override them. | | `timeoutMs` | `600000` | Max total poll time. | | `maxConsecutiveErrors` | `10` | Abort after this many consecutive hard errors. | | `backoff` | `factor` 1.5, `maxIntervalMs` 15000 | Backoff between polls. | Return `false` from `onUpdate` to stop early; return `true` (or nothing) to continue. ## Route options The `routeOptions` parameter on `tradeAsset` (and the `options` field on `LifiRoutesRequest`) accepts a `LifiRoutesRequestOptions` object: | Name | Type | Description | | ---------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `slippage` | `number` | Maximum acceptable slippage as a decimal (e.g. `0.005` for 0.5%). | | `order` | `'FASTEST' \| 'CHEAPEST'` | Route ordering preference. | | `insurance` | `boolean` | Whether to include bridge insurance. | | `bridges` | `LifiToolsConfiguration` | Allow/deny specific bridge tools. | | `exchanges` | `LifiToolsConfiguration` | Allow/deny specific exchange tools. | | `allowSwitchChain` | `boolean` | Allow routes that require switching chains mid-route. | | `allowDestinationCall` | `boolean` | Allow contract calls on the destination chain. | | `integrator` | `string` | Your [LI.FI Portal](https://portal.li.fi/) integrator ID. Required when you pass `fee`. | | `fee` | `number` | Integrator fee as a decimal (e.g. `0.005` for 0.5%). Range: `0 <= fee < 1`. Requires `integrator` set to your [LI.FI Portal](https://portal.li.fi/) integrator ID. | | `maxPriceImpact` | `number` | Maximum price impact as a decimal. | | `referrer` | `string` | Optional tracking string passed to LI.FI. Does not affect fee payout. | *** ## Low-level methods Use these methods when you want full control over quotes, route selection, signing, and status polling instead of calling `tradeAsset`. ## Getting a Quote Use the `getQuote` method to get a quote for bridging or swapping tokens across chains. ```typescript theme={null} const address = await portal.getEip155Address(); if (!address) return; const request = { fromChain: 'eip155:8453', // Base Mainnet toChain: 'eip155:42161', // Arbitrum fromToken: 'ETH', toToken: 'USDC', fromAddress: address, fromAmount: '100000000000000', // 0.0001 ETH (in wei) }; const response = await portal.trading.lifi.getQuote(request); const rawResponse = response.data?.rawResponse; if (rawResponse) { // Process quote response // You can sign and submit the transaction if transactionRequest is available if (rawResponse.transactionRequest) { await executeTransaction(rawResponse.transactionRequest, request.fromChain); } } ``` The response includes a `transactionRequest` object with the transaction details you'll need to sign and submit. ## Finding Routes Use the `getRoutes` method to discover available routes for your cross-chain transfer. ```typescript theme={null} const address = await portal.getEip155Address(); if (!address) return; const request = { fromChainId: 'eip155:8453', // Base Mainnet fromAmount: '100000000000000', // 0.0001 ETH (in wei) fromTokenAddress: 'ETH', toChainId: 'eip155:42161', // Arbitrum toTokenAddress: 'USDC', fromAddress: address, }; const response = await portal.trading.lifi.getRoutes(request); const rawResponse = response.data?.rawResponse; if (rawResponse) { const routes = rawResponse.routes; // Find recommended route const recommendedRoute = routes.find(route => route.tags?.includes('RECOMMENDED')) || routes[0]; if (recommendedRoute) { console.log('Selected route:', recommendedRoute.id); console.log('Steps:', recommendedRoute.steps.length); console.log('From:', recommendedRoute.fromAmountUSD, 'USD'); console.log('To:', recommendedRoute.toAmountUSD, 'USD'); // Process route steps await processRouteSteps(recommendedRoute.steps, request.fromChainId); } } ``` The response includes an array of routes with estimates, fees, and gas costs. Routes may be tagged as `RECOMMENDED`, `CHEAPEST`, or `FASTEST`. ## Getting Route Step Details Use the `getRouteStep` method to get detailed transaction information for a specific route step, including an unsigned transaction that you can then sign and submit to an RPC provider (the `transactionRequest` field). ```typescript theme={null} async function getStepTransactionDetails(step: LifiStep): Promise { try { const response = await portal.trading.lifi.getRouteStep(step); return response.data?.rawResponse || null; } catch (error) { console.error('Error getting step details:', error.message); return null; } } ``` The response includes a `transactionRequest` object with the unsigned transaction that you can sign and submit. ## Executing Swaps and Bridges After getting a quote or route step details, extract the transaction details from the `transactionRequest` object and sign the transaction. Extract the `from`, `to`, `value`, and `data` fields to sign and submit the transaction. ### Approving ERC-20 Tokens If your `fromToken` is an ERC-20, the Li.Fi router cannot move it on your behalf until you grant an on-chain allowance. Skip this step when the `fromToken` is the chain's native asset (its `address` is `0x0000000000000000000000000000000000000000`). Build the approval transaction with the `portal.delegations.approve` method, then sign each transaction it returns with the same `eth_sendTransaction` flow used to sign the swap. Call this helper after obtaining a quote and before calling `executeTransaction`: ```typescript theme={null} const NATIVE_TOKEN_ADDRESS = '0x0000000000000000000000000000000000000000'; async function approveErc20IfNeeded( quote: LifiQuoteResponse, fromAmount: string, fromChainId: string, ): Promise { const action = quote.data?.rawResponse?.action; const estimate = quote.data?.rawResponse?.estimate; if (!action || !estimate) return; const fromToken = action.fromToken; if (!fromToken || fromToken.address === NATIVE_TOKEN_ADDRESS) { // Native asset — no approval needed. return; } // Convert the raw fromAmount into the token's primary denomination // (e.g. raw "10000" with 6 decimals → "0.01"). Use a string-aware // conversion to preserve precision for large values. const amount = formatUnits(fromAmount, fromToken.decimals); // e.g. ethers.formatUnits or viem.formatUnits const { transactions } = await portal.delegations.approve({ chain: fromChainId, token: fromToken.address, delegateAddress: estimate.approvalAddress, amount, }); for (const tx of transactions) { const sendResponse = await portal.request({ chainId: fromChainId, method: 'eth_sendTransaction', params: [tx], }); const txHash = sendResponse.result as string; if (txHash) { await waitForConfirmation(txHash, fromChainId); } } } ``` This step only applies when the `fromToken` is an ERC-20. Native-asset swaps (ETH, MATIC, etc.) skip it. For more on the delegations API, see the [Manage Token Delegations](./delegations) guide. ### Signing and Submitting Transactions ```typescript theme={null} async function executeTransaction(transactionRequest: LifiTransactionRequest, chainId: string) { try { const from = transactionRequest.from; const to = transactionRequest.to; if (!from || !to) { console.error("Missing required 'from' or 'to' field"); return; } // Extract value. Default: 0x0 const value = transactionRequest.value || '0x0'; // Extract data const data = transactionRequest.data || ''; // Create transaction const ethTransaction = { from, to, value, data, // Let Portal handle gas estimation }; // Sign and send const txHash = await portal.request({ chainId, method: 'eth_sendTransaction', params: [ethTransaction], }) if (txHash) { console.log('Transaction submitted:', txHash); // Wait for on-chain confirmation (throws on failure) await waitForConfirmation(txHash, chainId); console.log('Transaction confirmed'); } } catch (error) { console.error('Error executing transaction:', error.message); } } ``` The `transactionRequest` from Li.Fi may include `gasPrice` and `gasLimit` fields. You can remove these if you want Portal to estimate the gas for you, or include them if you want to use Li.Fi's estimates. ### Processing Multiple Route Steps For routes with multiple steps, process them sequentially: ```typescript theme={null} async function processRouteSteps(steps: LifiStep[], fromChainId: string): Promise { for (let index = 0; index < steps.length; index++) { const step = steps[index]; console.log(`Processing step ${index + 1}/${steps.length}: ${step.tool}`); // 1. Get transaction details for this step const stepWithTx = await getStepTransactionDetails(step); const transactionRequest = stepWithTx?.transactionRequest; if (!transactionRequest) { console.error(`Failed to get transaction details for step ${index + 1}`); return false; } // 2. Sign and submit the transaction await executeTransaction(transactionRequest, fromChainId); console.log(`Step ${index + 1} completed`); } return true; } ``` ### Waiting for Transaction Confirmation The example below is for educational purposes showing low-level receipt polling. When using `tradeAsset`, confirmation failures automatically throw and abort the entire operation. The strict confirmation contract ensures no silent failures. ```typescript theme={null} async function waitForConfirmation( txHash: string, chainId: string, desiredConfirmations: number = 2, pollIntervalMs: number = 2000, timeoutSeconds: number = 600 ): Promise { const startTime = Date.now(); let minedBlock = null; // will be set once mined let confirmations = 0; while (true) { if (timeoutSeconds && (Date.now() - startTime) / 1000 > timeoutSeconds) { throw new Error(`Timeout waiting for ${desiredConfirmations} confirmations`); } try { if (!minedBlock) { const receiptRes = await axios.post( getRpcUrl(chainId), // Create a method to obtain the RPC URL for the network of the Li.Fi transaction step { jsonrpc: '2.0', id: 1, method: getRpcReceiptMethod(chainId), // i.e. 'eth_getTransactionReceipt' for non-abstracted eth accounts params: [txHash], } ); const receipt = receiptRes.data.result; // If the account is abstracted, it should be receiptRes?.data?.result?.receipt. if (!receipt) { await new Promise(r => setTimeout(r, pollIntervalMs)); continue; } // For Ethereum-based chains, check if the transaction failed by checking: receipt.status === '0x0' if (checkIfStatusFailed(receipt, chainId)) { throw new Error(`Transaction ${txHash} reverted`); } minedBlock = parseInt(receipt.blockNumber, 16); // fall through to Phase 2 on the same loop iteration if possible } // Phase 2: already mined → now we only need latest block number const latestBlockRes = await axios.post( getRpcUrl(chainId), // Create a method to obtain the RPC URL for the network of the Li.Fi step transaction { jsonrpc: '2.0', id: 2, method: getRpcLatestBlockMethod(chainId), params: [], } ); /* * If on eth, then the getConfirmations function would do this: * const latestBlock = parseInt(latestBlockRes.data.result, 16) * return latestBlock - minedBlock + 1 */ confirmations = getConfirmations(latestBlockRes, minedBlock); if (confirmations >= desiredConfirmations) { return true; } } catch (err) { console.error('Polling error:', err.message); } await new Promise(r => setTimeout(r, pollIntervalMs)); } } ``` ## Tracking Transaction Status Use the `getStatus` method to track the status of your cross-chain transfer. ```typescript theme={null} async function trackLiFiStatus(txHash: string, fromChain: string) { const request = { txHash, fromChain, }; try { const response = await portal.trading.lifi.getStatus(request); const rawResponse = response.data?.rawResponse; if (rawResponse) { console.log('Status:', rawResponse.status); if (rawResponse.transactionId) { console.log('Transaction ID:', rawResponse.transactionId); } if (rawResponse.lifiExplorerLink) { console.log('Explorer:', rawResponse.lifiExplorerLink); } // Check if complete switch (rawResponse.status) { case 'DONE': console.log('Transfer completed successfully!'); break; case 'FAILED': console.error('Transfer failed'); break; default: console.log('Transfer in progress...'); } } } catch (error) { console.error('Error getting status:', error.message); } } ``` ### Polling for Cross-Chain Completion For cross-chain transfers, poll the status endpoint until the transfer completes: ```typescript theme={null} async function pollForCompletion( txHash: string, fromChain: string, maxAttempts: number = 300, pollIntervalMs: number = 2000 ): Promise { for (let attempt = 0; attempt < maxAttempts; attempt++) { try { const request = { txHash, fromChain, }; const response = await portal.trading.lifi.getStatus(request); const rawResponse = response.data?.rawResponse; if (rawResponse) { console.log(`Polling (${attempt + 1}/${maxAttempts}): ${rawResponse.status}`); switch (rawResponse.status) { case 'DONE': return true; case 'FAILED': return false; default: // Continue polling break; } } } catch (error) { // Continue polling on error console.error('There was an error in a polling intent:', error); } await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); } return false; // Timeout } ``` ## Example Flow Here's a complete example of executing a cross-chain bridge: ```typescript theme={null} // 1. Get user address const userAddress = await portal.getEip155Address(); if (!userAddress) return; // 2. Get a quote const quoteRequest = { fromChain: 'eip155:8453', // Base Mainnet toChain: 'eip155:42161', // Arbitrum fromToken: 'ETH', toToken: 'USDC', fromAddress: userAddress, fromAmount: '100000000000000', // 0.0001 ETH (in wei) }; const quoteResponse = await portal.trading.lifi.getQuote(quoteRequest); let txHash: string | null = null; const quote = quoteResponse.data?.rawResponse; const transactionRequest = quote?.transactionRequest; if (!transactionRequest) { console.error('No quote available'); return; } // 3. Approve the fromToken if it's an ERC-20 (no-op for native assets) await approveErc20IfNeeded(quoteResponse, quoteRequest.fromAmount, quoteRequest.fromChain); // 4. Extract transaction parameters const from = transactionRequest.from; const to = transactionRequest.to; if (!from || !to) { console.error('Missing required fields'); return; } const value = transactionRequest.value || '0x0'; const data = transactionRequest.data || ''; // 5. Sign and submit the transaction const ethTransaction = { from, to, value, data, }; txHash = await portal.request({ chainId: quoteRequest.fromChain, method: 'eth_sendTransaction', params: [ethTransaction], }) if (!txHash) { console.error('Failed to submit transaction'); return; } console.log('Transaction submitted:', txHash); // 6. Track status for cross-chain completion if (txHash) { const completed = await pollForCompletion(txHash, quoteRequest.fromChain); if (completed) { console.log('Bridge completed successfully!'); } else { console.error('Bridge failed or timed out'); } } ``` ## Best Practices 1. **Compare quotes/routes** before signing and submitting the transaction(s) to find the best option for your use case 2. **Process steps sequentially** for multi-step routes, ensuring each step completes before starting the next 3. **Handle network errors gracefully** and provide user feedback 4. **Monitor transaction status** for cross-chain transfers, as they may take longer than single-chain transactions 5. **Validate user balances** before initiating swaps or bridges ## Supported Networks Portal's Li.Fi integration supports the following mainnet networks: * Monad (`eip155:143`) * Ethereum (`eip155:1`) * Optimism (`eip155:10`) * BSC (`eip155:56`) * Gnosis (`eip155:100`) * Unichain (`eip155:130`) * Polygon (`eip155:137`) * Sonic (`eip155:146`) * Mantle (`eip155:5000`) * Base (`eip155:8453`) * Arbitrum (`eip155:42161`) * Celo (`eip155:42220`) * Avalanche (`eip155:43114`) * Linea (`eip155:59144`) * Berachain (`eip155:80094`) * Katana (`eip155:747474`) * Solana (`solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`) * Bitcoin (`bip122:000000000019d6689c085ae165831e93-p2wpkh`) For the complete list of networks Li.Fi supports across its ecosystem, refer to the [Li.Fi documentation](https://docs.li.fi). If you need a chain that isn't listed above, contact Portal support. **Testnets are not supported.** ## Next Steps * Learn about [signing transactions](./sign-a-transaction) * Explore [sending tokens](./send-tokens) * Check out [Portal API methods](./portal-api-methods) * See the [Client API Li.Fi endpoints](../../../apis/client/manual-reference#lifi-integration) # Logging Configuration Source: https://docs.portalhq.io/sdks/web/guide/logging Configure logging for Portal SDK operations in the Web SDK with log levels and custom loggers. The Portal Web SDK provides built-in logging capabilities to help you debug and monitor SDK operations during development and production. The interface matches the [React Native SDK](/sdks/react-native/guide/logging) for consistency across platforms. ## Quick Start Enable logging by setting the `logLevel` parameter when initializing Portal: ```typescript theme={null} import Portal from '@portal-hq/web' const portal = new Portal({ rpcConfig: { 'eip155:1': 'https://...', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': 'https://...', }, apiKey: 'YOUR_PORTAL_CLIENT_API_KEY', logLevel: 'debug', // Enable debug-level logging }) ``` ## Log Levels The SDK supports five log levels, ordered by verbosity: | Level | Description | Use Case | | --------- | --------------------------------------------------------- | ------------------------------- | | `'none'` | No logging output (default) | Production environments | | `'error'` | Only errors and critical failures | Production with minimal logging | | `'warn'` | Errors and warnings | Staging and production | | `'info'` | Errors, warnings, and informational messages | Development and staging | | `'debug'` | All log messages including detailed debugging information | Local development only | **Default:** `'none'` — No logs are emitted unless you explicitly configure logging. ### Logger Interface Your custom logger must implement the `ILogger` interface (exported from `@portal-hq/web`): ```typescript theme={null} interface ILogger { error: (...args: unknown[]) => void warn: (...args: unknown[]) => void info: (...args: unknown[]) => void debug: (...args: unknown[]) => void } ``` ### Custom logger example ```typescript theme={null} import Portal from '@portal-hq/web' import type { ILogger } from '@portal-hq/web' const customLogger: ILogger = { error: (...args) => console.error('[MyApp]', ...args), warn: (...args) => console.warn('[MyApp]', ...args), info: (...args) => console.info('[MyApp]', ...args), debug: (...args) => console.debug('[MyApp]', ...args), } const portal = new Portal({ rpcConfig: { /* ... */ }, apiKey: 'your-api-key', logLevel: 'debug', logger: customLogger, }) ``` ### Production logger with timestamps For better observability, add timestamps to your logs: ```typescript theme={null} const portalLogger: ILogger = { error: (...args: unknown[]) => { const timestamp = new Date().toISOString() console.error(`[${timestamp}] [PORTAL ERROR]`, ...args) }, warn: (...args: unknown[]) => { const timestamp = new Date().toISOString() console.warn(`[${timestamp}] [PORTAL WARN]`, ...args) }, info: (...args: unknown[]) => { const timestamp = new Date().toISOString() console.info(`[${timestamp}] [PORTAL INFO]`, ...args) }, debug: (...args: unknown[]) => { const timestamp = new Date().toISOString() console.debug(`[${timestamp}] [PORTAL DEBUG]`, ...args) }, } const portal = new Portal({ rpcConfig: { /* ... */ }, apiKey: 'YOUR_PORTAL_CLIENT_API_KEY', logLevel: 'info', logger: portalLogger, }) ``` ## How logging works 1. **SDK handles level filtering** — The SDK filters messages based on your `logLevel` setting before calling your logger methods. 2. **Logger handles output** — Your logger is responsible only for formatting and outputting the messages. 3. **No performance impact when disabled** — When `logLevel` is `'none'`, log messages are not generated. 4. **Fallback on logger errors** — If your logger throws, the SDK falls back to `console` for that call. ## Next Steps * [MPC Progress Callbacks](/sdks/web/guide/mpc-progress-callbacks) — Monitor MPC operation progress * [Portal API Methods](/sdks/web/guide/portal-api-methods) — Learn about available SDK methods * [Error Codes](/resources/error-codes) — Understand Portal error codes # Manage wallet lifecycle states Source: https://docs.portalhq.io/sdks/web/guide/manage-wallet-lifecycle-states Users can have multiple states in their wallet lifecycle: having a wallet, having wallet backups, having certain recovery methods available, and more. After initializing Portal for your user, it's crucial to determine their wallet's lifecycle state to ensure a healthy wallet state. We provide several helper methods to easily derive the state of your users' wallets: ```typescript theme={null} // Checks if the user has created a wallet on any device. await portal.doesWalletExist() // Checks if the user's wallet share is on their current device. await portal.isWalletOnDevice() ``` These methods can be used together to understand the user's wallet state and determine the next steps. For example: ```typescript theme={null} async function manageWalletState(portal: Portal): Promise { try { // Determine the wallet's state. const walletExists = await portal.doesWalletExist(); const walletExistsOnDevice = await portal.isWalletOnDevice(); if (!walletExists) { // Create and back up the wallet using portal.createWallet() and portal.backup(). } else if (!walletExistsOnDevice) { // Recover the wallet using portal.recover(). } else { // The wallet is ready to use. } } catch (error) { // Handle any errors that occur. } } ``` And that's it! You're now helping your users maintain a safe and secure experience with their wallets while using your apps. # Meld buy, sell, and transfer Source: https://docs.portalhq.io/sdks/web/guide/meld Use portal.ramps.meld in the Web SDK to embed Meld buy, sell, and transfer sessions via the Portal iframe and Client API. The Web SDK exposes [Meld](/integrations/On-Off-Ramp/meld) buy, sell, and transfer crypto through `portal.ramps.meld`. Each method sends a message to the embedded Portal iframe, which calls Portal's Meld integration on the [Client API](/apis/quickstart) using your client credentials. You do not call Meld's servers directly from the browser. For one-time dashboard setup and webhook configuration, see [Meld integration overview](/integrations/On-Off-Ramp/meld). For the end-to-end widget flow using raw HTTP calls, see the [White-Label API guide](/integrations/On-Off-Ramp/meld-widget). ## Prerequisites * An [initialized Portal client](./getting-started) with the iframe ready (`onReady` or equivalent). * Meld enabled for your Portal environment and [configured in the dashboard](/integrations/On-Off-Ramp/meld). ## Architecture | Layer | Role | | -------------- | ----------------------------------------------------------------------------------------- | | Your app | Calls `portal.ramps.meld.*` | | Portal Web SDK | `postMessage` bridge to the Portal iframe (use `portal.ramps.meld`, not lower-level APIs) | | Portal iframe | Forwards to `POST/GET …/api/v3/clients/me/integrations/meld/...` with the Portal session | | Meld | Aggregated on/off-ramp network; iframe-based widget session | Prefer `portal.ramps.meld` over lower-level APIs. The SDK types for requests and responses live in `@portal-hq/web` (see the [Web SDK reference](../reference) section **portal.ramps.meld (Meld)**). ## Types and responses Successful Client API responses use an envelope `{ data: T, metadata?: Record }`. Methods on `portal.ramps.meld` return `Promise` of that envelope (for example `MeldCreateRetailWidgetResponse` is `{ data: { widgetUrl: string, ... } }`). Throwing or rejected promises usually indicate network errors, iframe timeouts, or API error payloads surfaced by the SDK — handle them with `try/catch` like other async Portal calls. *** ## Customer methods ### createCustomer Creates a Meld customer record for the current Portal client. This is optional — the widget collects identity inline. Pre-creating is useful when you want to pre-fill KYC fields or track multiple sessions against a persistent customer record. ```typescript theme={null} import Portal from '@portal-hq/web' const portal = new Portal({ rpcConfig: { 'eip155:1': 'https://...' }, apiKey: 'YOUR_API_KEY', }) portal.onReady(async () => { const { data } = await portal.ramps.meld.createCustomer({ name: { firstName: 'Jane', lastName: 'Doe' }, email: 'jane@example.com', phone: '+15551234567', dateOfBirth: '1990-01-15', type: 'INDIVIDUAL', }) console.log(data.id) // Meld customer ID — pass as `customerId` on createRetailWidget console.log(data.externalId) // Portal-derived external ID }) ``` **Signature** ```typescript theme={null} public async createCustomer( data: MeldCreateCustomerRequest ): Promise ``` | Parameter | Type | Required | Description | | ------------------ | ---------------------------- | -------- | --------------------------------------------- | | `data.name` | `MeldCustomerName` | No | `{ firstName?: string; lastName?: string }` | | `data.email` | `string` | No | Customer email address. | | `data.phone` | `string` | No | E.164 phone number. | | `data.dateOfBirth` | `string` | No | ISO date string (for example `"1990-01-15"`). | | `data.type` | `"INDIVIDUAL" \| "BUSINESS"` | No | Customer type. | **Returns** — `MeldCreateCustomerResponse`: `{ data: MeldCustomer }`. If a customer already exists for the current Portal client, the Portal API returns `409 Conflict` with the existing customer ID at `details.meldCustomerId`. Retrieve the full record with `searchCustomer()` instead. *** ### searchCustomer Returns the Meld customer record(s) associated with the current Portal client. ```typescript theme={null} portal.onReady(async () => { const { data } = await portal.ramps.meld.searchCustomer() if (data.customers.length > 0) { const customerId = data.customers[0].id console.log('Existing customer:', customerId) } }) ``` **Signature** ```typescript theme={null} public async searchCustomer(): Promise ``` **Returns** — `MeldSearchCustomerResponse`: `{ data: { customers: MeldCustomer[]; count: number; remaining: number } }`. `data.customers` will be empty if no customer has been created for this Portal client yet. *** ## Retail methods ### getRetailQuote Fetches live pricing across Meld's provider network before opening the widget. Returns one `MeldQuote` per available provider. ```typescript theme={null} portal.onReady(async () => { const { data } = await portal.ramps.meld.getRetailQuote({ countryCode: 'US', sourceCurrencyCode: 'USD', destinationCurrencyCode: 'USDC', sourceAmount: 100, }) const best = data.quotes[0] console.log(`${best.serviceProvider}: ${best.destinationAmount} USDC for $${best.sourceAmount}`) console.log('Total fee:', best.totalFee) }) ``` **Signature** ```typescript theme={null} public async getRetailQuote( data: MeldGetRetailQuoteRequest ): Promise ``` | Parameter | Type | Required | Description | | ------------------------------ | ---------- | -------- | ----------------------------------------------------------------------------------------------------------- | | `data.countryCode` | `string` | Yes | ISO-3166-1 alpha-2 country code (for example `"US"`). | | `data.sourceCurrencyCode` | `string` | Yes | Source currency (fiat for buy; crypto [Meld Code](https://www.meld.io/coverage/cryptocurrencies) for sell). | | `data.destinationCurrencyCode` | `string` | Yes | Destination currency. | | `data.sourceAmount` | `number` | Yes | Amount of source currency to convert. | | `data.walletAddress` | `string` | No | Destination wallet address — informs provider eligibility. | | `data.customerId` | `string` | No | Meld customer ID when pre-created. | | `data.paymentMethodType` | `string` | No | Filter to a specific payment method (for example `"CREDIT_DEBIT_CARD"`). | | `data.serviceProviders` | `string[]` | No | Restrict the quote to specific providers. | | `data.subdivision` | `string` | No | State/region code when required (for example US states). | **Returns** — `MeldGetRetailQuoteResponse`: `{ data: { quotes: MeldQuote[]; message?: string; error?: string; timestamp?: string } }`. Each `MeldQuote` includes required fields: `serviceProvider`, `transactionType`, `sourceAmount`, `sourceCurrencyCode`, `destinationAmount`, `destinationCurrencyCode`, `exchangeRate`, `transactionFee`, `totalFee`, `paymentMethodType`; and optional nullable fields: `sourceAmountWithoutFees`, `destinationAmountWithoutFees`, `networkFee`, `partnerFee`, `fiatAmountWithoutFees`, `countryCode`, `customerScore`, `institutionName`, `isNativeAvailable`, `rampIntelligence`. *** ### createRetailWidget Creates a Meld widget session and returns a `widgetUrl`. Open the URL in a new browser tab to let the user complete the buy/sell/transfer flow. ```typescript theme={null} portal.onReady(async () => { const { data } = await portal.ramps.meld.createRetailWidget({ sessionType: 'BUY', sessionData: { countryCode: 'US', serviceProvider: 'TRANSAK', // From getRetailQuote response sourceCurrencyCode: 'USD', sourceAmount: '100', // String-encoded decimal destinationCurrencyCode: 'USDC', walletAddress: portal.address, redirectUrl: 'https://yourapp.example/meld/return', }, }) // Open widget in a new tab if (data.widgetUrl) { window.open(data.widgetUrl, '_blank', 'noopener,noreferrer') } }) ``` **Signature** ```typescript theme={null} public async createRetailWidget( data: MeldCreateRetailWidgetRequest ): Promise ``` | Parameter | Type | Required | Description | | ------------------------ | ------------------------------- | -------- | ------------------------------------------------------------------------ | | `data.sessionType` | `"BUY" \| "SELL" \| "TRANSFER"` | Yes | Flow direction. | | `data.sessionData` | `MeldSessionData` | Yes | Session configuration (see below). | | `data.externalSessionId` | `string` | No | Your reference for this session — recorded on Meld's transaction. | | `data.customerId` | `string` | No | Meld customer ID from `createCustomer` or `searchCustomer`. | | `data.bypassKyc` | `boolean` | No | Skip KYC where allowed by the provider and jurisdiction. Use cautiously. | **`MeldSessionData` fields** | Field | Type | Required | Description | | ------------------------- | ---------- | -------- | --------------------------------------------------------------------- | | `countryCode` | `string` | Yes | ISO-3166-1 alpha-2 country code. | | `serviceProvider` | `string` | Yes | Provider chosen from the quote response. | | `sourceCurrencyCode` | `string` | Yes | Source currency. | | `sourceAmount` | `string` | Yes | Source amount as a string-encoded decimal. | | `destinationCurrencyCode` | `string` | Yes | Destination currency. | | `walletAddress` | `string` | No | Wallet address. Pre-fills the widget. | | `walletTag` | `string` | No | Memo or destination tag for chains that require it. | | `paymentMethodType` | `string` | No | Pre-selects the payment method. | | `lockFields` | `string[]` | No | Field names the user cannot modify (for example `["walletAddress"]`). | | `redirectUrl` | `string` | No | URL Meld redirects to when the user finishes the flow. | **Returns** — `MeldCreateRetailWidgetResponse`: `{ data: { id: string; token: string; customerId: string; externalCustomerId: string; externalSessionId: string; widgetUrl: string } }`. `widgetUrl` embeds a single-use session token. Do not persist or reuse it. Refer to [Meld's Provider UI Launch Implementation guide](https://docs.meld.io/docs/whitelabel-api-guide#provider-ui-launch-implementation) for iframe sizing guidance and supported `lockFields` values. *** ### searchRetailTransactions Lists Meld retail transactions for the current Portal client with optional filtering. ```typescript theme={null} portal.onReady(async () => { const { data } = await portal.ramps.meld.searchRetailTransactions() console.log(`${data.count} of ${data.totalCount} transactions`) data.transactions.forEach(tx => { console.log(tx.id, tx.status, tx.transactionType) }) // With filters const pending = await portal.ramps.meld.searchRetailTransactions({ status: 'PENDING', limit: '20', offset: '0', }) }) ``` **Signature** ```typescript theme={null} public async searchRetailTransactions( data?: MeldSearchRetailTransactionsParams ): Promise ``` | Parameter | Type | Required | Description | | ------------- | -------- | -------- | -------------------------------------------------------------------- | | `data.status` | `string` | No | Filter by transaction status (for example `"SETTLED"`, `"PENDING"`). | | `data.limit` | `string` | No | Maximum number of results to return. | | `data.offset` | `string` | No | Number of results to skip for pagination. | **Returns** — `MeldSearchRetailTransactionsResponse`: `{ data: { transactions: MeldTransaction[]; count: number; remaining: number; totalCount: number } }`. *** ### getRetailTransaction Fetches a single retail transaction by its Meld transaction ID. ```typescript theme={null} portal.onReady(async () => { const { data } = await portal.ramps.meld.getRetailTransaction('tx_01HAAAA') console.log(data.transaction.status) console.log(data.transaction.destinationAmount, data.transaction.destinationCurrencyCode) }) ``` **Signature** ```typescript theme={null} public async getRetailTransaction(id: string): Promise ``` | Parameter | Type | Required | Description | | --------- | -------- | -------- | -------------------- | | `id` | `string` | Yes | Meld transaction ID. | **Returns** — `MeldGetRetailTransactionResponse`: `{ data: { transaction: MeldTransaction } }`. *** ### getRetailTransactionBySession Fetches the retail transaction associated with a widget session ID. ```typescript theme={null} portal.onReady(async () => { const sessionId = widgetResponse.data.id // From createRetailWidget response const { data } = await portal.ramps.meld.getRetailTransactionBySession(sessionId) console.log(data.transaction.status) }) ``` **Signature** ```typescript theme={null} public async getRetailTransactionBySession( sessionId: string ): Promise ``` | Parameter | Type | Required | Description | | ----------- | -------- | -------- | --------------------------------------------------------------- | | `sessionId` | `string` | Yes | Meld session ID from `createRetailWidget` response (`data.id`). | **Returns** — `MeldGetRetailTransactionResponse`: `{ data: { transaction: MeldTransaction } }`. Prefer Meld webhooks over polling for transaction lifecycle updates. Configure them in [Meld Dashboard](https://dashboard.meld.io/) — see [Webhooks](/integrations/On-Off-Ramp/meld-webhooks). *** ## Discovery methods Discovery endpoints return Meld's current catalog of supported countries, currencies, payment methods, and limits. Call them at startup or lazily before populating dropdowns. Cache discovery responses client-side — countries, currencies, and payment methods change rarely and re-querying on every page load adds unnecessary latency. All discovery methods accept an optional `MeldDiscoveryParams` object. It accepts `countryCode` and any additional filter keys Meld documents. ### getServiceProviders ```typescript theme={null} portal.onReady(async () => { const { data } = await portal.ramps.meld.getServiceProviders({ countryCode: 'US' }) data.forEach(provider => { console.log(provider.serviceProvider, provider.name, provider.categories) }) }) ``` **Signature** ```typescript theme={null} public async getServiceProviders( params?: MeldDiscoveryParams ): Promise ``` **Returns** — `MeldGetServiceProvidersResponse`: `{ data: MeldServiceProvider[] }`. Each `MeldServiceProvider` includes: `serviceProvider`, `name`, `status`, `categories`, `categoryStatuses`, `websiteUrl`, `customerSupportUrl`, and `logos` (dark/light variants). *** ### getCountries ```typescript theme={null} portal.onReady(async () => { const { data } = await portal.ramps.meld.getCountries() data.forEach(country => { console.log(country.countryCode, country.name) }) }) ``` **Signature** ```typescript theme={null} public async getCountries( params?: MeldDiscoveryParams ): Promise ``` **Returns** — `MeldGetCountriesResponse`: `{ data: MeldCountry[] }`. Each `MeldCountry` includes: `countryCode`, `name`, `flagImageUrl`, and optional `regions: { regionCode, name }[]`. *** ### getFiatCurrencies ```typescript theme={null} portal.onReady(async () => { const { data } = await portal.ramps.meld.getFiatCurrencies({ countryCode: 'US' }) }) ``` **Signature** ```typescript theme={null} public async getFiatCurrencies( params?: MeldDiscoveryParams ): Promise ``` **Returns** — `MeldGetFiatCurrenciesResponse`: `{ data: MeldFiatCurrency[] }`. Each `MeldFiatCurrency` includes: `currencyCode`, `name`, `symbolImageUrl`. *** ### getCryptoCurrencies ```typescript theme={null} portal.onReady(async () => { const { data } = await portal.ramps.meld.getCryptoCurrencies() // Each token has a chain-specific Meld Code (currencyCode) data.forEach(crypto => { console.log(crypto.currencyCode, crypto.name, crypto.chainCode) }) }) ``` **Signature** ```typescript theme={null} public async getCryptoCurrencies( params?: MeldDiscoveryParams ): Promise ``` **Returns** — `MeldGetCryptoCurrenciesResponse`: `{ data: MeldCryptoCurrency[] }`. Each `MeldCryptoCurrency` includes: `currencyCode`, `name`, `chainCode`, `chainName`, `chainId`, `contractAddress`, `symbolImageUrl`. Meld doesn't accept a separate chain parameter — each token has a distinct `currencyCode` per chain (a "Meld Code"). Pass the same code from the quote into the widget session so pricing and settlement match. *** ### getPaymentMethods ```typescript theme={null} portal.onReady(async () => { const { data } = await portal.ramps.meld.getPaymentMethods({ countryCode: 'US' }) }) ``` **Signature** ```typescript theme={null} public async getPaymentMethods( params?: MeldDiscoveryParams ): Promise ``` **Returns** — `MeldGetPaymentMethodsResponse`: `{ data: MeldPaymentMethod[] }`. Each `MeldPaymentMethod` includes: `paymentMethod`, `name`, `paymentType`, and optional `logos`. *** ### getDefaults Returns the default fiat currency and payment methods per country. ```typescript theme={null} portal.onReady(async () => { const { data } = await portal.ramps.meld.getDefaults({ countryCode: 'US' }) const usDefaults = data.find(d => d.countryCode === 'US') console.log(usDefaults?.defaultCurrencyCode, usDefaults?.defaultPaymentMethods) }) ``` **Signature** ```typescript theme={null} public async getDefaults( params?: MeldDiscoveryParams ): Promise ``` **Returns** — `MeldGetDefaultsResponse`: `{ data: MeldCountryDefault[] }`. Each `MeldCountryDefault` includes: `countryCode`, `defaultCurrencyCode`, `defaultPaymentMethods: string[]`. *** ### getBuyLimits Returns minimum, maximum, and default purchase amounts per fiat currency. ```typescript theme={null} portal.onReady(async () => { const { data } = await portal.ramps.meld.getBuyLimits({ countryCode: 'US' }) const usd = data.find(l => l.currencyCode === 'USD') console.log(`USD buy: $${usd?.minimumAmount} – $${usd?.maximumAmount}`) }) ``` **Signature** ```typescript theme={null} public async getBuyLimits( params?: MeldDiscoveryParams ): Promise ``` **Returns** — `MeldGetBuyLimitsResponse`: `{ data: MeldFiatCurrencyPurchaseLimit[] }`. Each `MeldFiatCurrencyPurchaseLimit` includes: `currencyCode`, `defaultAmount`, `minimumAmount`, `maximumAmount`. *** ### getSellLimits Returns minimum, maximum, and default sell amounts per crypto currency. ```typescript theme={null} portal.onReady(async () => { const { data } = await portal.ramps.meld.getSellLimits() }) ``` **Signature** ```typescript theme={null} public async getSellLimits( params?: MeldDiscoveryParams ): Promise ``` **Returns** — `MeldGetSellLimitsResponse`: `{ data: MeldCryptoCurrencySellLimit[] }`. Each `MeldCryptoCurrencySellLimit` includes: `currencyCode`, `chainCode`, `defaultAmount`, `minimumAmount`, `maximumAmount`. *** ### getKycLimits Returns transaction limits per KYC tier and fiat currency. ```typescript theme={null} portal.onReady(async () => { const { data } = await portal.ramps.meld.getKycLimits() const usd = data.find(l => l.currencyCode === 'USD') console.log('Level 1 daily limit:', usd?.level1?.dailyLimit) console.log('Level 2 monthly limit:', usd?.level2?.monthlyLimit) }) ``` **Signature** ```typescript theme={null} public async getKycLimits( params?: MeldDiscoveryParams ): Promise ``` **Returns** — `MeldGetKycLimitsResponse`: `{ data: MeldKycFiatLevel[] }`. Each `MeldKycFiatLevel` includes: `currencyCode` and optional `level1`, `level2`, `level3` of type `MeldKycLimitTier`, each containing `dailyLimit`, `weeklyLimit`, `monthlyLimit`, `yearlyLimit`, and `transactionLimit`. *** ## End-to-end example The following example shows the recommended buy flow: discover, quote, create a widget session, and look up the result. ```typescript theme={null} import Portal from '@portal-hq/web' const portal = new Portal({ rpcConfig: { 'eip155:1': 'https://...' }, apiKey: 'YOUR_API_KEY', }) portal.onReady(async () => { // 1. Discover — get buy limits for US const limitsRes = await portal.ramps.meld.getBuyLimits({ countryCode: 'US' }) const usdLimit = limitsRes.data.find(l => l.currencyCode === 'USD') console.log(`Min: $${usdLimit?.minimumAmount}, Max: $${usdLimit?.maximumAmount}`) // 2. Quote const quoteRes = await portal.ramps.meld.getRetailQuote({ countryCode: 'US', sourceCurrencyCode: 'USD', destinationCurrencyCode: 'USDC', sourceAmount: 100, walletAddress: portal.address, }) const best = quoteRes.data.quotes[0] // 3. Create widget session const widgetRes = await portal.ramps.meld.createRetailWidget({ sessionType: 'BUY', sessionData: { countryCode: 'US', serviceProvider: best.serviceProvider, sourceCurrencyCode: 'USD', sourceAmount: '100', destinationCurrencyCode: 'USDC', walletAddress: portal.address, }, }) // 4. Open widget in new tab if (widgetRes.data.widgetUrl) { window.open(widgetRes.data.widgetUrl, '_blank', 'noopener,noreferrer') } // 5. After the user returns, look up the transaction const txRes = await portal.ramps.meld.getRetailTransactionBySession(widgetRes.data.id) console.log('Transaction status:', txRes.data.transaction.status) }) ``` *** ## Error handling Wrap calls in `try/catch`. Portal forwards Meld's HTTP status codes (4xx/5xx) and preserves the upstream error message. Common Portal-side errors: * `400 Meld integration is not enabled` — turn on the Meld integration for the current Portal environment in the [Portal Dashboard](https://app.portalhq.io). * `400 Meld API key is not configured for this environment` — paste a valid Meld API key into the integration config. * `409 Conflict` (on `createCustomer`) — a Meld customer already exists for this Portal client. Call `searchCustomer()` to retrieve it. ## Related documentation * [Web SDK reference](../reference) (section **portal.ramps.meld (Meld)**) * [Meld integration overview](/integrations/On-Off-Ramp/meld) * [White-Label API guide](/integrations/On-Off-Ramp/meld-widget) * [Webhooks](/integrations/On-Off-Ramp/meld-webhooks) # MPC progress callbacks Source: https://docs.portalhq.io/sdks/web/guide/mpc-progress-callbacks This section describes how to check in on the status of each MPC operation ## Progress Callback Support The `portal.createWallet()`, `portal.backupWallet()`, and `portal.recoverWallet()` functions all support an optional argument for a progress callback. This is a function that will be triggered at key points within these respective MPC operations to provide incremental updates, These incremental updates enable you to update your UI as the MPC operation progresses. ## Using Progress Callbacks ```typescript theme={null} import { BackupMethods, MpcStatus } from '@portal-hq/web' // Example using createWallet() const address = await portal.createWallet((update: MpcStatus) => { console.log(`Status update:`, update.status) console.log(`MPC Operation done:`, update.done) }) // Example using backupWallet() const { cipherText } = await portal.backupWallet(BackupMethods.passkey, (update: MpcStatus) => { console.log(`Status update:`, update.status) console.log(`MPC Operation done:`, update.done) }) // Example using recoverWallet() const address = await portal.recoverWallet(cipherText, BackupMethods.passkey, {}, (update: MpcStatus) => { console.log(`Status update:`, update.status) console.log(`MPC Operation done:`, update.done) }) ``` ## MPC Statuses These are all the possible statuses that will be returned through the callbacks on the MPC operations. ```typescript theme={null} generatingShare = "Generating share" parsingShare = "Parsing share" storingShare = "Storing share" encryptingShare = "Encrypting share" decryptingShare = "Decrypting share" readingShare = "Reading share" recoveringBackupShare = "Recovering backup share" recoveringSigningShare = "Recovering signing share" done = "Done" ``` ### Generate Status Flow ```json theme={null} {"done": false, "status": "Generating share"} {"done": false, "status": "Storing share"} {"done": true, "status": "Done"} ``` ### Backup Status Flow ```json theme={null} {"done": false, "status": "Reading share"} {"done": false, "status": "Generating share"} {"done": false, "status": "Encrypting share"} {"done": false, "status": "Storing share"} {"done": true, "status": "Done"} ``` ### Recover Status Flow ```json theme={null} {"done": false, "status": "Reading share"} {"done": false, "status": "Decrypting share"} {"done": false, "status": "Parsing share"} {"done": false, "status": "Recovering signing share"} {"done": false, "status": "Generating share"} {"done": false, "status": "Storing share"} {"done": false, "status": "Recovering backup share"} {"done": false, "status": "Generating share"} {"done": false, "status": "Encrypting share"} {"done": false, "status": "Storing share"} {"done": true, "status": "Done"} ``` # Noah virtual accounts and payouts Source: https://docs.portalhq.io/sdks/web/guide/noah Use portal.ramps.noah in the Web SDK for Noah KYC, payins, payouts, and quotes via the Portal iframe and Client API. The Web SDK exposes [Noah](/integrations/On-Off-Ramp/noah) virtual accounts and global payouts through `portal.ramps.noah`. Each method sends a message to the embedded Portal iframe, which calls Portal’s Noah integration on the [Client API](/apis/quickstart) using your client credentials. You do not call Noah’s servers directly from the browser. For dashboard setup, signing keys, and supported CAIP-2 networks, see [Noah integration overview](/integrations/On-Off-Ramp/noah). For HTTP shapes and webhooks, see the [Noah workflow guides](/integrations/On-Off-Ramp/noah#workflow-guides) and [Noah Business API / EMM documentation](https://docs.noah.com/). ## Prerequisites * An [initialized Portal client](./getting-started) with the iframe ready (`onReady` or equivalent). * Noah enabled for your Portal environment and [configured in the dashboard](/integrations/On-Off-Ramp/noah). * For payins and payouts, the end user must complete [Noah KYC](/integrations/On-Off-Ramp/noah-kyc) with approved status before those flows succeed. ## Architecture | Layer | Role | | ----------------- | ---------------------------------------------------------------------------------------- | | Your app | Calls `portal.ramps.noah.*` | | Portal Web SDK | `postMessage` bridge to the Portal iframe (do not call `Mpc` directly) | | Portal iframe | Forwards to `POST/GET …/api/v3/clients/me/integrations/noah/...` with the Portal session | | Noah (via Portal) | Hosted KYC, banking rails, settlement | Prefer `portal.ramps.noah` over lower-level APIs. The SDK types for requests and responses live in `@portal-hq/web` (see the [Web SDK reference](../reference) section **portal.ramps.noah (Noah)**). ## Types and responses Successful Client API responses use an envelope `{ data: T, metadata?: Record }`. Methods on `portal.ramps.noah` return `Promise` of that envelope (for example `NoahInitiateKycResponse` is `{ data: { hostedUrl: string } }`). Throwing or rejected promises usually indicate network errors, iframe timeouts, or API error payloads surfaced by the SDK—handle them with `try/catch` like other async Portal calls. ## initiateKyc Starts hosted Noah onboarding. Open `data.hostedUrl` in a new browser context (for example `window.open` with `noopener,noreferrer`). Validate **HTTPS** and the **hostname** against the checkout domains Noah documents for your environment (extend the example allowlist accordingly). ```typescript theme={null} import Portal from '@portal-hq/web'; const portal = new Portal({ rpcConfig: { 'eip155:1': 'https://...' }, apiKey: 'YOUR_API_KEY', }); portal.onReady(async () => { const { data } = await portal.ramps.noah.initiateKyc({ returnUrl: 'https://yourapp.example/noah/return', fiatOptions: [{ fiatCurrencyCode: 'USD' }], customerType: 'Individual', }); const url = new URL(data.hostedUrl); const allowedHosts = new Set([ 'checkout.noah.com', 'checkout.sandbox.noah.com', 'staging-checkout.noah.com', ]); if (url.protocol !== 'https:' || !allowedHosts.has(url.hostname)) { throw new Error('Invalid KYC URL host or scheme'); } window.open(url.toString(), '_blank', 'noopener,noreferrer'); }); ``` **Signature** ```typescript theme={null} public async initiateKyc(data: NoahInitiateKycRequest): Promise ``` | Parameter | Type | Required | Description | | ------------------- | -------------------------------- | -------- | ------------------------------------------------------- | | `data.returnUrl` | `string` | Yes | HTTPS URL where Noah returns the user after onboarding. | | `data.fiatOptions` | `{ fiatCurrencyCode: string }[]` | No | Fiat currencies to present in onboarding. | | `data.customerType` | `'Individual' \| 'Business'` | No | Onboarding flow variant. | | `data.metadata` | `Record` | No | Opaque metadata forwarded per API rules. | | `data.form` | `Record` | No | Optional prefill payload for hosted forms. | **Returns** — `NoahInitiateKycResponse`: `{ data: { hostedUrl: string } }`. This call only starts onboarding; KYC outcome and status changes arrive asynchronously via Noah **`Customer`** webhooks. See [Noah webhooks](/integrations/On-Off-Ramp/noah-webhooks). See also: [Noah KYC guide](/integrations/On-Off-Ramp/noah-kyc), [Noah hosted flows](https://docs.noah.com/). ## initiatePayin Creates a fiat-to-stablecoin payin and returns bank instructions and a `payinId`. Use a [supported CAIP-2 network](/integrations/On-Off-Ramp/noah#supported-networks) and the user’s wallet address as `destinationAddress`. ```typescript theme={null} const { data } = await portal.ramps.noah.initiatePayin({ fiatCurrency: 'USD', cryptoCurrency: 'USDC_TEST', network: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', destinationAddress: 'SoLAddr1111111111111111111111111111111111111', }); console.log(data.payinId, data.bankDetails); ``` **Signature** ```typescript theme={null} public async initiatePayin(data: NoahInitiatePayinRequest): Promise ``` | Parameter | Type | Required | Description | | ------------------------- | -------- | -------- | ------------------------------------------------------------------------ | | `data.fiatCurrency` | `string` | Yes | Fiat currency code (for example `USD`). | | `data.cryptoCurrency` | `string` | Yes | Noah crypto asset code (for example stablecoin test symbols in sandbox). | | `data.network` | `string` | Yes | CAIP-2 chain identifier. | | `data.destinationAddress` | `string` | Yes | Address that receives crypto after settlement. | Business fees are configured per environment in the Portal Dashboard (Integrations → Noah → Business fees) and applied automatically to payins and payouts. They can't be set per request. **Returns** — `NoahInitiatePayinResponse`: `{ data: { payinId: string; cryptoCurrency: string; fee: NoahFeeDetails; bankDetails: BankDetails } }`. `NoahFeeDetails` shape: `{ fiatCurrencyCode: string; totalFeePct: string; totalFeeBase: string; totalFeeMin: string }`. `BankDetails` includes: * `paymentMethodId` — payment method identifier * `paymentMethodType` — payment rail type * `accountNumber` — bank account number * `cryptoCurrency` — crypto currency for this payin * `network` — network identifier * `fee` — fee breakdown (`NoahFeeDetails`) * `accountHolderName?` — optional account holder name * `bankCode?` — optional bank routing/sort code * `bankName?` — optional bank name * `bankAddress?` — optional bank address with `street`, `street2?`, `city`, `postCode`, `state`, `country` * `reference?` — optional payment reference * `relatedPaymentMethods?` — optional array of related payment methods Payin lifecycle updates are asynchronous; track them with Noah **`FiatDeposit`** and **`Transaction`** webhooks, not by polling this SDK response. See [Noah webhooks](/integrations/On-Off-Ramp/noah-webhooks). See also: [Payins](/integrations/On-Off-Ramp/noah-payins), [FiatDeposit webhooks](https://docs.noah.com/api-concepts/webhooks/fiat-deposits/). ## simulatePayin Sandbox-oriented call to estimate fees or eligibility for a payin without creating a live payin. Typical body includes a Noah `paymentMethodId` and fiat amount. ```typescript theme={null} await portal.ramps.noah.simulatePayin({ paymentMethodId: 'pm-1', fiatAmount: '10', fiatCurrency: 'USD', }); ``` **Signature** ```typescript theme={null} public async simulatePayin(data: NoahSimulatePayinRequest): Promise ``` | Parameter | Type | Required | Description | | ---------------------- | -------- | -------- | ------------------------------------ | | `data.paymentMethodId` | `string` | Yes | Payment method identifier from Noah. | | `data.fiatAmount` | `string` | Yes | Fiat amount as a string (decimal). | | `data.fiatCurrency` | `string` | Yes | Fiat currency code. | **Returns** — `NoahSimulatePayinResponse`: `{ data: { fiatDepositId: string; reference?: string } }`. ## getPayoutCountries Lists countries available for fiat payouts. ```typescript theme={null} const { data } = await portal.ramps.noah.getPayoutCountries(); console.log(data.countries); ``` **Signature** ```typescript theme={null} public async getPayoutCountries(): Promise ``` **Returns** — `NoahGetPayoutCountriesResponse`: `{ data: { countries: Record } }`. ## getPayoutChannels Returns payout rails available for a crypto currency. `country` and `fiatCurrency` narrow results but are optional; `fiatAmount` can further refine channel availability. ```typescript theme={null} const { data } = await portal.ramps.noah.getPayoutChannels({ country: 'US', cryptoCurrency: 'USDC_TEST', fiatCurrency: 'USD', fiatAmount: '10', pageSize: 10, pageToken: 'NEXT_PAGE_TOKEN', }); ``` **Signature** ```typescript theme={null} public async getPayoutChannels(data: NoahGetPayoutChannelsRequest): Promise ``` | Parameter | Type | Required | Description | | ---------------------- | -------- | -------- | ------------------------------------------------------------------- | | `data.cryptoCurrency` | `string` | Yes | Crypto asset code for the payout leg. | | `data.country` | `string` | No | ISO country code (for example `US`). Narrows results when provided. | | `data.fiatCurrency` | `string` | No | Fiat currency for the payout. Narrows results when provided. | | `data.fiatAmount` | `string` | No | Amount string used for filtering or quotes. | | `data.paymentMethodId` | `string` | No | Filters channels compatible with a specific payment method. | | `data.pageSize` | `number` | No | Maximum number of payout channels to return. | | `data.pageToken` | `string` | No | Pagination token returned from a previous response. | **Returns** — `NoahGetPayoutChannelsResponse`: `{ data: { items: Channel[]; pageToken?: string } }`. Each `Channel` includes: * `id` — channel identifier * `paymentMethodCategory` — `'Bank' | 'Card' | 'Identifier'` * `paymentMethodType` — payment rail type (`NoahPaymentMethodType`) * `fiatCurrency` — fiat currency code * `country` — ISO country code * `limits` — `{ minLimit: string; maxLimit?: string }` * `rate` — exchange rate as a string * `processingSeconds` — estimated settlement time * `calculated?` — optional `{ totalFee: string }` * `processingTier?` — `'Standard' | 'Priority'` * `issuer?` — optional issuer identifier * `paymentMethods?` — `PaymentMethodDisplay[]` See also: [Payouts](/integrations/On-Off-Ramp/noah-payouts). ## getPayoutChannelForm Loads the dynamic form schema for a channel so you can collect recipient fields before requesting a quote. ```typescript theme={null} const { data } = await portal.ramps.noah.getPayoutChannelForm('ch-1'); // Render form fields from `data` per Noah’s schema ``` **Signature** ```typescript theme={null} public async getPayoutChannelForm(channelId: string): Promise ``` | Parameter | Type | Required | Description | | ----------- | -------- | -------- | -------------------------------------------- | | `channelId` | `string` | Yes | Channel identifier from `getPayoutChannels`. | **Returns** — `NoahGetPayoutChannelFormResponse`: `{ data: { formSchema?: Record; formMetadata?: { contentHash: string } } }`. ## getPayoutQuote Requests fees and crypto amount estimates for a payout. Include `form` when the channel requires recipient data. ```typescript theme={null} const { data } = await portal.ramps.noah.getPayoutQuote({ channelId: 'ch-1', cryptoCurrency: 'USDC_TEST', fiatAmount: '10', form: { /* channel-specific fields */ }, }); console.log(data.payoutId, data.formSessionId, data.cryptoAmountEstimate, data.totalFee); ``` **Signature** ```typescript theme={null} public async getPayoutQuote(data: NoahGetPayoutQuoteRequest): Promise ``` | Parameter | Type | Required | Description | | ---------------------- | ------------------------- | -------- | ----------------------------------------------------------------------------- | | `data.channelId` | `string` | Yes | Payout channel id. | | `data.cryptoCurrency` | `string` | Yes | Crypto asset for the quote. | | `data.fiatAmount` | `string` | No | Fiat amount as a string. Mutually exclusive with `cryptoAmount`; provide one. | | `data.cryptoAmount` | `string` | No | Crypto amount as a string. Mutually exclusive with `fiatAmount`; provide one. | | `data.quoted` | `boolean` | No | When `true`, requests a firm signed quote alongside the estimate. | | `data.form` | `Record` | No | Recipient fields from the channel form. | | `data.fiatCurrency` | `string` | No | Fiat currency override when needed. | | `data.paymentMethodId` | `string` | No | Payment method hint when applicable. | **Returns** — `NoahGetPayoutQuoteResponse`: `{ data: { payoutId: string; totalFee: string; cryptoAmountEstimate: string; cryptoAuthorizedAmount: string; formSessionId: string; rate?: string; breakdown?: TransactionBreakdownItem[]; quote?: NoahSellQuote; cryptoCurrency?: string; fiatCurrency?: string; fiatAmount?: string; nextStep?: FormNextStep } }`. * `cryptoAuthorizedAmount` — authorized crypto amount for the payout * `rate?` — effective exchange rate for this quote * `breakdown?` — fee breakdown array (`{ type: 'ChannelFee' | 'BusinessFee' | 'Remaining'; amount: string }[]`) * `quote?` — firm signed quote when `quoted: true` was requested (`{ signedQuote: string; expiry: string }`) * `cryptoCurrency?`, `fiatCurrency?`, `fiatAmount?` — disclosure fields echoing back the quote parameters When present, `nextStep` includes: * `stepId` — `'Vop' | 'Cob' | 'PaymentDetails'` * `stepType` — `'Ack'` or `'DataEntry'` * `schema` — form schema for the next step ## initiatePayout Executes a payout after quoting. For crypto-sourced payouts you may need to pass deposit `conditions` from the quote response via a `trigger` payload; align with [Payouts](/integrations/On-Off-Ramp/noah-payouts) and Noah’s on-chain deposit triggers. ```typescript theme={null} const payoutId = 'p1'; const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z'); // Use a stable nonce per payout attempt (max 36 chars; reuse on retries) const nonce = crypto.randomUUID(); const { data } = await portal.ramps.noah.initiatePayout({ payoutId, sourceAddress: 'SoLAddr1111111111111111111111111111111111111', expiry, nonce, network: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', }); console.log(data.destinationAddress, data.conditions); ``` **Signature** ```typescript theme={null} public async initiatePayout(data: NoahInitiatePayoutRequest): Promise ``` | Parameter | Type | Required | Description | | -------------------- | -------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | `data.payoutId` | `string` | Yes | Identifier from `getPayoutQuote`. | | `data.sourceAddress` | `string` | Yes | Address funding the crypto leg when applicable. | | `data.expiry` | `string` | Yes | ISO-8601 expiry for the deposit authorization. | | `data.nonce` | `string` | Yes | Stable nonce for this payout attempt; **reuse on retry** so repeated calls stay idempotent. Must be ≤ 36 characters per Noah API constraints. | | `data.network` | `string` | Yes | CAIP-2 network for the deposit leg. | | `data.trigger` | `NoahOnchainDepositSourceTriggerInput` | No | Explicit on-chain deposit trigger. Supports `Single`, `Permanent`, and `Quoted` variants (discriminated on `Type`). | | `data.fiatAmount` | `string` | No | Fiat amount for the payout. Only applicable when `trigger.Type` is `'SingleOnchainDepositSourceTriggerInput'`. | **Returns** — `NoahInitiatePayoutResponse`: `{ data: { destinationAddress: string | null; conditions: DepositSourceTriggerCondition[]; ruleId?: string } }`. Each `DepositSourceTriggerCondition` includes: * `amountConditions` — array of `{ comparisonOperator: 'EQ' | 'LTEQ' | 'GTEQ'; value: string }` * `cryptoCurrency` — crypto asset code * `network` — CAIP-2 network identifier * `destinationAddress` — destination address for the deposit After this call returns `destinationAddress` and `conditions`, submit the onchain transfer to satisfy them. You can do this with any wallet — including Portal's own [send method](/sdks/web/guide/send-tokens) (`portal.sendAsset(...)`) on the same `Portal` instance, which builds, signs, and broadcasts in one call. This call initiates the payout flow; completion and failures are reported asynchronously via Noah **`Transaction`** webhooks. See [Noah webhooks](/integrations/On-Off-Ramp/noah-webhooks). See also: [Transaction events](https://docs.noah.com/api-concepts/webhooks/transactions/), [automated payout recipes](https://docs.noah.com/recipes/payout/automated-payouts). ## getPaymentMethods Returns payment methods available to the customer (for example cards or bank rails), including pagination tokens when present. ```typescript theme={null} const { data } = await portal.ramps.noah.getPaymentMethods({ pageSize: 10, pageToken: 'NEXT_PAGE_TOKEN', capability: 'PayoutTo' }); console.log(data.paymentMethods, data.pageToken); ``` **Signature** ```typescript theme={null} public async getPaymentMethods(data?: NoahGetPaymentMethodsRequest): Promise ``` | Parameter | Type | Required | Description | | ----------------- | ----------------------------------------- | -------- | ----------------------------------------------------- | | `data.pageSize` | `number` | No | Maximum number of payment methods to return. | | `data.pageToken` | `string` | No | Pagination token returned from a previous response. | | `data.capability` | `'PayoutFrom' \| 'PayinTo' \| 'PayoutTo'` | No | Filters payment methods by supported Noah capability. | **Returns** — `NoahGetPaymentMethodsResponse`: `{ data: { paymentMethods: PaymentMethod[]; pageToken?: string } }`. Each `PaymentMethod` includes: * `id` — payment method identifier * `paymentMethodCategory` — `'Bank' | 'Card' | 'Identifier'` * `paymentMethodType` — payment rail type * `details` — payment method details with `type`, optional `accountNumber`, `bankCode`, `last4`, `scheme`, `identifierType`, `identifier`, `routingNumber`, `swiftCode`, `bankingSystems`, `bankName`, `bankAddress` * `accountHolderDetails?` — optional holder info with `name?: { firstName: string; lastName: string; middleName?: string }` * `issuerDetails?` — optional issuer info with `name?: string` ## Error handling Wrap calls in `try/catch`. Log or surface errors without printing full API responses in production if they might contain sensitive identifiers. Retry only for idempotent reads unless your product team confirms otherwise. ## Related documentation * [Web SDK reference — Noah](../reference) (section **portal.ramps.noah (Noah)**) * [Noah integration overview](/integrations/On-Off-Ramp/noah) * [KYC](/integrations/On-Off-Ramp/noah-kyc), [Payins](/integrations/On-Off-Ramp/noah-payins), [Payouts](/integrations/On-Off-Ramp/noah-payouts), [Webhooks](/integrations/On-Off-Ramp/noah-webhooks) * [Noah docs — API concepts](https://docs.noah.com/api-concepts/transactions/) * [Noah docs — authentication & signing](https://docs.noah.com/api-concepts/authentication/signing/) # Perform swaps Source: https://docs.portalhq.io/sdks/web/guide/perform-swaps This guide will walk you through how to perform swaps with Portal's Web SDK. Please contact sales for additional pricing information on using the Swaps integrations at [partner@portalhq.io](mailto:partner@portalhq.io) Portal's Swaps integration allows you to easily extend your existing `Portal` instance with swaps functionality. ## Fetching a list of Sources The `getSources()` function returns an object containing valid, swappable token sources that can be used with your Portal MPC Wallet. The existing `Portal` instance that was provided at initialization is used to scope the request to the current chain. In order to retrieve this list, you can use the following code. ```typescript theme={null} const fetchSources = async (): Record => { return await portal.getSources('YOUR_SWAPS_API_KEY', 'eip155:1') } ``` ### The returned list The object returned by the `getSources()` function is a set of key/value pairs where the key is the name of the token and the value is the token address for that token. ## Fetching a swap Quote The `getQuote()` function returns a `Quote` object for a given set of arguments. ### QuoteArgs The minimum requirements for a valid set of `Quote` arguments are a `buyToken`, a `sellToken`, and either a `buyAmount` or a `sellAmount`. An example of a valid `QuoteArgs` object would look something like this. ```typescript theme={null} import { QuoteArgs } from '@portal-hq/swaps' const quoteArgs: QuoteArgs = { buyToken: 'UNI', sellToken: 'ETH', sellAmount: 1000, // Amount is in base units, so WEI for ETH } ``` ### Using your QuoteArgs Once you have a valid `QuoteArgs` object, you can call the `getQuote()` function. ```typescript theme={null} const fetchQuote = async (quoteArgs: QuoteArgs): QuoteResponse { return await portal.getQuote('YOUR_SWAPS_API_KEY', quoteArgs, 'eip155:1') } ``` ### The Quote object The `Quote` object includes two properties: * `allowanceTarget` The address requiring allowances to complete the Swap * `cost` The total cost (gas fees) of the swap transaction * `transaction` The transaction to perform the swap ## Executing a Swap If you're selling ERC20 tokens, you'll need to utilize the `allowanceTarget` to give the associated contract permissions to access a given amount of the token you're selling. This requires a token allowance of at least what you're selling. If you're selling `ETH`, this step is not necessary. When you're ready to execute a swap, you can use the `Provider` on your existing `Portal` instance to sign the transaction and make a request to chain. ```typescript theme={null} import { Eip1559, LegacyTx } from '@portal-hq/provider/types' const executeSwap = async (transaction: Eip1559 | LegacyTx) => { const txHash = await portal.ethSendTransaction('eip155:1', transaction) } ``` # Portal API methods Source: https://docs.portalhq.io/sdks/web/guide/portal-api-methods Portal API's convenient helper methods are designed to streamline data retrieval from the Portal REST API. #### **`portal.getClient`** Gets all of the Portal client's relevant details, such as their wallet addresses, their signing share pairs' statuses, their backup share pairs' statuses, and more. ```typescript theme={null} const portalClient = await portal.getClient() ```
Example response ```json theme={null} { "createdAt": "2025-04-04T21:41:52.354Z", "custodian": { "id": "id", "name": "My Organization" }, "ejectedAt": null, "environment": { "id": "id", "backupWithPortalEnabled": true, "isMultiBackupEnabled": true, "name": "Production" }, "id": "clientId", "isAccountAbstracted": false, "metadata": { "namespaces": { "solana": { "address": "CDjKJBeidJ7KqR4uuQvfXSbeNUNBzZ69mSauXHKAUZqZ", "curve": "ED25519" }, "stellar": { "address": "GCTLGX2EFNK2ICF6N27MALUNO6NNZBI3MD47MI5ULET34DPMIG6YQPPQ", "curve": "ED25519" }, "bip122": { "address": "", "curve": "SECP256K1", "bitcoin": { "p2wpkh": { "mainnet": "bc1qmt4g8tehe5ap35l7n2hm264jdgp0yq3feftt8d", "testnet": "tb1qmt4g8tehe5ap35l7n2hm264jdgp0yq3fn0scu7" } } }, "eip155": { "address": "0xfb03795d3a61db4874bc247bfe1e9363c2384437", "curve": "SECP256K1" }, "tron": { "address": "TYrSmbGbsuVQB6HfraicoNK33JzAUcJZa9", "curve": "SECP256K1" } } }, "wallets": [ { "createdAt": "2025-04-04T22:02:19.127Z", "curve": "ED25519", "ejectableUntil": null, "id": "id", "backupSharePairs": [], "signingSharePairs": [ { "createdAt": "2025-04-04T22:02:19.131Z", "id": "id", "status": "completed" } ], "publicKey": "{\"x\":\"123\",\"y\":\"123\"}" }, { "createdAt": "2025-04-04T22:02:18.474Z", "curve": "SECP256K1", "ejectableUntil": null, "id": "id", "backupSharePairs": [], "signingSharePairs": [ { "createdAt": "2025-04-04T22:02:18.478Z", "id": "id", "status": "completed" } ], "publicKey": "{\"x\":\"123\",\"y\":\"123\"}" } ] } ```
#### **`portal.getAssets`** Fetches a list of non-fungible tokens (NFTs). The response is an array of objects where each object represents a unique NFT. ```typescript theme={null} const assets = portal.getAssets( 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', // CAIP-2 chain ID false // (optional) include NFTs ) ```
Example response ```json theme={null} { "nativeBalance": { "balance": "0.00501002", "decimals": 9, "name": "Solana", "rawBalance": "5010020", "symbol": "SOL", "metadata": {} }, "tokenBalances": [ { "balance": "0.01", "decimals": 6, "name": "USD Coin", "rawBalance": "10000", "symbol": "USDC", "metadata": { "tokenAccountAddress": "6sVvsCBKYDWrZkE1txPU4PtcwfUnw4UDN1S78Eo7Eizm", "tokenMintAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" } }, { "balance": "0.01", "decimals": 6, "name": "Tether USD", "rawBalance": "10000", "symbol": "USDT", "metadata": { "tokenAccountAddress": "aBJYKdVpZXPVxQ7uNRk3SPWEdtARm63WEchGxNMzCa9", "tokenMintAddress": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" } }, { "balance": "0.01", "decimals": 6, "name": "PayPal USD", "rawBalance": "10000", "symbol": "PYUSD", "metadata": { "tokenAccountAddress": "CrdsCZTqvEr6nL7sC9St2RfMnVeYbitGqoEcvaZpFuk9", "tokenMintAddress": "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo" } } ] } ```
#### **`portal.buildTransaction`** Creates an unsigned eip155/solana transaction for transferring assets to another address on a specific chain. You can then use this unsigned eip155/solana transaction to sign and submit the eip155/solana transaction. ```typescript theme={null} const txDetails = await portal.buildTransaction( 'sepolia', // Friendly chain or CAIP-2 chainId '0xDestinationAddress', 'USDC', // Friendly token name, or token address (mint address for Solana) '1', // Sends 1 USDC ) ```
Example response (Sending USDC on Ethereum Sepolia) ```json theme={null} { "transaction": { "from": "0xc690a7F8F02D551287C049997C9A003Ffe65CAD5", "to": "0xf08A50178dfcDe18524640EA6618a1f965821715", "data": "0xa9059cbb000000000000000000000000dfd8302f44727a6348f702ff7b594f127de3a90200000000000000000000000000000000000000000000000000000000000186a0" }, "metadata": { "amount": "0.1", "fromAddress": "0xc690a7F8F02D551287C049997C9A003Ffe65CAD5", "toAddress": "0xdFd8302f44727A6348F702fF7B594f127dE3A902", "tokenAddress": "0xf08A50178dfcDe18524640EA6618a1f965821715", "tokenDecimals": 6, "rawAmount": "100000" } } ```
Example response (Sending USDC on Solana Devnet) ```json theme={null} { "transaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAUIYOC3CyvldvId8bF28rNypn1C7OBqvhXqEYXLKLeWh6ou38jGPjCqlLFY3TlPbCHB6R+g/1F1WqGrCV4DL8GB9XhhA/bXqsIjAl2JBhtZTr77wOyNjGsBM3b+kC2y56V7AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA7RCyzkSFX8TqTPQE0KC0DK1/+zQGi2/G3eQYI3wAupwHVrOJiBDitgxRzQDkrbLih58c3zcw12JO80+paiyM8jJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkG3fbh12Whk9nL4UbO63msHLSF7V9bN5E6jPWFfv8AqbQF5ecBPqhVgg4ZLgfMMY9c0oT1wlGamtcEtNX/+C0iAgYGAAEFBAMHAAcDAgEACQOghgEAAAAAAA==", "metadata": { "amount": "0.1", "fromAddress": "7XAsvsVfvzQu61gHpa1QDMFF5Zsz5DrU6u2LAizNrXPX", "toAddress": "8APEEA4SHrfGteABQcUmH2yEHy7nEe8DqgvmKGAStHR", "tokenMintAddress": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", "tokenDecimals": 6, "tokenProgramId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "tokenExtensions": [], "rawAmount": "100000", "lastValidBlockHeight": "342868333", "serializedTransactionBase64Encoded": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAUIYOC3CyvldvId8bF28rNypn1C7OBqvhXqEYXLKLeWh6ou38jGPjCqlLFY3TlPbCHB6R+g/1F1WqGrCV4DL8GB9XhhA/bXqsIjAl2JBhtZTr77wOyNjGsBM3b+kC2y56V7AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA7RCyzkSFX8TqTPQE0KC0DK1/+zQGi2/G3eQYI3wAupwHVrOJiBDitgxRzQDkrbLih58c3zcw12JO80+paiyM8jJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkG3fbh12Whk9nL4UbO63msHLSF7V9bN5E6jPWFfv8AqbQF5ecBPqhVgg4ZLgfMMY9c0oT1wlGamtcEtNX/+C0iAgYGAAEFBAMHAAcDAgEACQOghgEAAAAAAA==", "serializedTransactionBase58Encoded": "4c5ErVEQWyXMS7yGF2TJaJBf7Vk3bkdaXx6LrCS3xkeLUPWnJMde1CmjFFxEEwbwLjX6N2u2RNpNFhTPML7ow4ZDpUPB5NkxQpiNawgUhYUj9bBDeFQfXjYzFFjs7fVD6jRJzmqQSCrdWFiFP2rwMiHXiZqj4MFY7HFwnfymJp3zaXxfG2VxEXVJU5ii9ddvjynNg7jJvhZ4ysUpjTJkWAVdj3UCJAkaqjZoeapmFVm73113uAJV2arPqnNtoouj8vcHYNKLdA1NnG3T8ZSAeePWTmruEH9uCtMpXgiNefgwvA4szZ6DNKDCAREUWXH38cLH3XQjgjLDdRXmZ8xQHYacuwmtXVVjdUzV3rcVuC1NM4YmHkRnw8nfULm4PmrZoBxchnS7vNXxXHBAd9ysg1VQp5v21oBsgjegZMrxfo8wzr3HHvjXoBw3LeBJ1raWYAAa1S32oE19Hvqhm3EKFaF9txb46j4kHVDqbmPuVQDCzAHAjMZHiALqL2MCQJGimS86gfq7d", "unsignedTransactionMessage": { "signatures": null, "message": { "accountKeys": [ "7XAsvsVfvzQu61gHpa1QDMFF5Zsz5DrU6u2LAizNrXPX", "49yfs2fpy9HhVRU6icApq3QvNdYRBrtcRJojTT7jnUAx", "96uj2xF4aT7JfkvsYXjeejaFMtzPYQwyMDti29QXWeux", "11111111111111111111111111111111", "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", "8APEEA4SHrfGteABQcUmH2yEHy7nEe8DqgvmKGAStHR", "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" ], "header": { "numRequiredSignatures": 1, "numReadonlySignedAccounts": 0, "numReadonlyUnsignedAccounts": 5 }, "instructions": [ { "programIdIndex": 6, "accounts": [ 0, 1, 5, 4, 3, 7 ], "data": "" }, { "programIdIndex": 7, "accounts": [ 2, 1, 0 ], "data": "3gJqkocMWaMm" } ], "recentBlockhash": "D7jbugcuXsPj7zejJ5i3xSHQU9Jj1mpsVw7hUTawLcrd" } } } } ```
*** Looking for more functionality? Explore our other Client API endpoints [here](../../../apis/client/reference). # Recover a wallet Source: https://docs.portalhq.io/sdks/web/guide/recover-a-wallet This guide will walk you through how to use your users' backups to recover their wallet. ## Portal-Managed Recovery (default) When using Portal-Managed Backups, you can simply call the `portal.recoverWallet()` function passing an empty string for the `cipherText` parameter. Portal will then fetch the `cipherText` from our backend and complete the recovery process. ```typescript theme={null} // With Password backup await portal.recoverWallet('', BackupMethods.password, { passwordStorage: { password: "${USER_PROVIDED_PASSWORD}" } }) // With GDrive backup await portal.recoverWallet('', BackupMethods.gdrive) // With PasskeyBackups await portal.recoverWallet('', BackupMethods.passkey) // With Firebase Auth Backup (configure Firebase storage and ensure user is signed in) await portal.recoverWallet('', BackupMethods.firebase) ``` **WARNING**: To recover a wallet with the Portal SDK, your device must be configured to use passcode authentication. Please note that if you disable your passcode authentication after executing the `recover` function, you will need to run the `recover` function again. ## Self-Managed Recovery By default, Portal manages storing both the encrypted client backup share and the custodian backup share for you. If you prefer to store and manage the backup shares in your own infrastructure instead of using Portal-Managed Backups, see our [Self-Managed Backups](../../../resources/self-managed-backups) guide. Before recovering, you will need to retrieve the encrypted client backup share from your API. You will then provide the encrypted client backup share to `portal.recoverWallet`. Here's an example of how that might look in your code: ```typescript theme={null} // Fetch the encrypted client backup share from your API. const cipherText = await axios.get('/users/[userId]/user-backup-share?backupMethod=GDRIVE') // Recover replaces your current signing shares (if there are any) with new ones. await portal.recoverWallet(cipherText, BackupMethods.gdrive) ``` **WARNING**: To recover a wallet with the Portal SDK, your device must be configured to use passcode authentication. Please note that if you disable your passcode authentication after executing the `recover` function, you will need to run the `recover` function again. ## Passkey Recovery To recover a wallet that was backed up with a passkey: ```typescript theme={null} import React from 'react' const RecoveryButton: React.FC = () => { const handleRecovery = async () => { // Portal fetches the cipherText automatically const address = await portal.recoverWallet( '', BackupMethods.passkey, {}, (status) => console.log('Recovery progress:', status) ) console.log('Recovered wallet address:', address) } return ( ) } export default RecoveryButton ``` ```typescript theme={null} import axios from 'axios' import React from 'react' const RecoveryButton: React.FC = () => { const handleRecovery = async () => { // Fetch the cipherText from your backend const response = await axios.get('{your_server}/users/[userId]/user-backup-share') const cipherText = response.data.cipherText const address = await portal.recoverWallet( cipherText, BackupMethods.passkey, {}, (status) => console.log('Recovery progress:', status) ) console.log('Recovered wallet address:', address) } return ( ) } export default RecoveryButton ``` ### Custom Domain Passkeys Recovery If you configured a custom domain for passkeys (see [Custom Domain Passkeys](./back-up-a-wallet#custom-domain-passkeys)), use the following recovery flow: ```typescript theme={null} import React from 'react' import { PasskeyOptions } from '@portal-hq/web/types' const passkeyOptions: PasskeyOptions = { customDomain: 'https://passkeys.yourapp.com', // Your configured subdomain. This will be the same across all environments, even local. relyingPartyId: 'yourapp.com', // Your root domain. For local dev set this to `localhost` relyingPartyName: 'Your App Name', // Displayed in passkey prompts usePopup: false, // Direct WebAuthn calls } const RecoveryButton: React.FC = () => { const handleRecovery = async () => { try { // Step 1: Authenticate with passkey to get encryption key const encryptionKey = await portal.authenticatePasskeyAndRetrieveKey(passkeyOptions) // Step 2: Recover wallet - Portal fetches cipherText automatically const address = await portal.recoverWallet( '', BackupMethods.custom, { customStorage: { encryptionKey } }, (status) => console.log('Recovery progress:', status) ) console.log('Recovered wallet address:', address) } catch (error) { console.error('Recovery failed:', error) } } return ( ) } ``` ```typescript theme={null} import axios from 'axios' import React from 'react' import { PasskeyOptions } from '@portal-hq/web/types' const passkeyOptions: PasskeyOptions = { customDomain: 'https://passkeys.yourapp.com', // Your configured subdomain. This will be the same across all environments, even local. relyingPartyId: 'yourapp.com', // Your root domain. For local dev set this to `localhost` relyingPartyName: 'Your App Name', // Displayed in passkey prompts usePopup: false, // Direct WebAuthn calls } const RecoveryButton: React.FC = () => { const handleRecovery = async () => { try { // Step 1: Retrieve cipherText from your backend const response = await axios.get('{your_server}/users/[userId]/user-backup-share') const cipherText = response.data.cipherText if (!cipherText) { throw new Error('No backup found') } // Step 2: Authenticate with passkey to get encryption key const encryptionKey = await portal.authenticatePasskeyAndRetrieveKey(passkeyOptions) // Step 3: Recover wallet with the encryption key const address = await portal.recoverWallet( cipherText, BackupMethods.custom, { customStorage: { encryptionKey } }, (status) => console.log('Recovery progress:', status) ) console.log('Recovered wallet address:', address) } catch (error) { console.error('Recovery failed:', error) } } return ( ) } ``` **Important:** You must have called `portal.storedClientBackupShare(true, BackupMethods.custom)` after successfully storing the cipherText during backup. If this was not called, recovery will fail with the error "Cannot run recovery, backup was not completed successfully". ## Firebase Auth recovery Firebase recovery uses the same TBS-backed encryption key flow as backup. Call [`configureFirebaseStorage`](/sdks/web/reference#configurefirebasestorage) with the same `getToken` implementation you use for backup, and ensure the user is signed in to Firebase before calling `recoverWallet`. The tab examples below assume a configured `portal` instance (same pattern as other recovery snippets in this guide). ```typescript theme={null} import React from 'react' import { BackupMethods } from '@portal-hq/web' import { getAuth } from 'firebase/auth' const FirebaseRecoveryButton: React.FC = () => { const handleRecovery = async () => { const auth = getAuth() portal.configureFirebaseStorage({ getToken: async (options?: { forceRefresh?: boolean }) => { const user = auth.currentUser if (!user) { return null } return user.getIdToken(Boolean(options?.forceRefresh)) }, }) const address = await portal.recoverWallet( '', BackupMethods.firebase, {}, (status) => console.log('Recovery progress:', status), ) console.log('Recovered wallet address:', address) } return ( ) } export default FirebaseRecoveryButton ``` ```typescript theme={null} import axios from 'axios' import React from 'react' import { BackupMethods } from '@portal-hq/web' import { getAuth } from 'firebase/auth' const FirebaseRecoveryButton: React.FC = () => { const handleRecovery = async () => { const auth = getAuth() portal.configureFirebaseStorage({ getToken: async (options?: { forceRefresh?: boolean }) => { const user = auth.currentUser if (!user) { return null } return user.getIdToken(Boolean(options?.forceRefresh)) }, }) const response = await axios.get( '{your_server}/users/[userId]/user-backup-share?backupMethod=FIREBASE', ) const cipherText = response.data.cipherText const address = await portal.recoverWallet( cipherText, BackupMethods.firebase, {}, (status) => console.log('Recovery progress:', status), ) console.log('Recovered wallet address:', address) } return ( ) } export default FirebaseRecoveryButton ``` See [Back up a wallet — Firebase Auth backup](./back-up-a-wallet#firebase-auth-backup) for setup prerequisites and the [Firebase Auth Backup resource](../../../resources/backup-options/firebase-byo-auth). ## Progress Callbacks You can learn how to handle the progress callbacks for `portal.recoverWallet` [here](./mpc-progress-callbacks). ## Next steps Amazing! Your users can now have multiple backups and can easily recover their wallet. Next let's dive into handling sessions across multiple devices for your users. # Send tokens Source: https://docs.portalhq.io/sdks/web/guide/send-tokens Here's exactly how you can send tokens from your Portal wallet to another address. ## Fund your Wallet Now that you have a wallet, the next step is to get test tokens for it. You can fund your wallet using **`portal.receiveTestnetAsset`**. If you are looking for a greater variety of test tokens, we recommend exploring our [faucets page](../../../resources/testnet-faucets). The `chainId` will need to be a [CAIP-2](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md) compliant Chain ID. For more info on Chain ID formatting, see [this doc](../../../resources/chain-id-formatting). ```typescript theme={null} const chainId = 'eip155:10143'; // Monad Testnet const params = { amount: '0.01', // You will receive 0.01 MON token: 'NATIVE', // Token, use "NATIVE" for the chain's native token }; // Fund your Portal wallet const response = await portal.receiveTestnetAsset(chainId, params); console.log(`✅ Transaction hash: ${response.data.txHash}`); ``` ## Sending Tokens from your Wallet Portal provides two ways to send transactions: 1. **`portal.sendAsset()`** - A simple method for sending tokens from your Portal wallet. 2. **`portal.provider.request()`** - Direct access to the underlying web3 provider for custom transactions. (You can learn more about this method [here](./sign-a-transaction).) For most use cases, we recommend using **`portal.sendAsset()`** as shown in the examples below. ### Submitting an EVM Transaction ```typescript theme={null} const chainId = 'eip155:10143'; // Monad Testnet const params = { amount: '0.0001', // Sends 0.0001 MON to: '0xDestinationAddress', // The recipient address token: 'NATIVE', // Token, use "NATIVE" for the chain's native token signatureApprovalMemo: 'Send MON from web', // optional: shown during approval }; // Send the tokens const txHash = await portal.sendAsset(chainId, params); console.log(`✅ Transaction hash: ${txHash}`); ``` **Account Abstraction clients:** `txHash` is a **UserOperation hash**, not an on-chain transaction hash — it will **not** resolve on a block explorer such as Etherscan or Monadscan. The on-chain transaction hash is only assigned once the bundler includes the UserOperation on-chain. Look up the UserOperation hash on a UserOp explorer such as [JiffyScan](https://jiffyscan.xyz/) to find the resulting transaction hash. See [Account abstraction](../../../resources/account-abstraction). ### Submitting a Solana Transaction You will need **`SOL`** to submit a Solana transaction, which is not currently supported by **`portal.receiveTestnetAsset`**. You can find a faucet to get test **`SOL`** tokens [here](../../../resources/testnet-faucets). ```typescript theme={null} const chainId = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1'; // Solana Devnet const params = { amount: '0.0001', // Sends 0.0001 SOL to: '0xDestinationAddress', // The recipient address token: 'NATIVE', // Token, use "NATIVE" for the chain's native token }; // Send the tokens const txHash = await portal.sendAsset(chainId, params); console.log(`✅ Transaction hash: ${txHash}`); ``` ### Submitting a TRON Transaction ```typescript theme={null} const chainId = 'tron:nile'; // TRON Nile testnet (use 'tron:mainnet' for production) const params = { amount: '1', // Sends 1 TRX to: 'TRecipientAddress', // The recipient TRON address token: 'NATIVE', // Token, use "NATIVE" for TRX (the chain's native token) signatureApprovalMemo: 'Send TRX', // optional: shown during approval }; // Send the tokens — returns the TRON transaction ID const txId = await portal.sendAsset(chainId, params); console.log(`✅ Transaction ID: ${txId}`); ``` You just sent your first token from your Portal wallet, that's awesome! 🎉 ## Advanced: Controlling Gas Sponsorship If your client is using [Account Abstraction](../../../resources/account-abstraction), you can control whether Portal sponsors the gas fees for each transaction using the `sponsorGas` parameter in `SendAssetParams`. You can also pass an optional **`signatureApprovalMemo`** in `SendAssetParams`; it is displayed to the user during the signature approval flow. ### Example: User Pays Gas ```typescript theme={null} const chainId = 'eip155:11155111'; // Ethereum sepolia const recipientAddress = '0x...'; const amount = '0.000001'; const token = 'NATIVE'; const params1 = { to: recipientAddress, amount, token, sponsorGas: false, // Portal client pays transaction fees }; // Send the tokens const txHash = await portal.sendAsset(chainId, params1); ``` By setting `sponsorGas: false`, the Portal client will pay for the transaction fees instead of having them sponsored. This is useful for testing or when you want users to pay for specific operations. Omitting `sponsorGas` or setting it to `true` produces the same behavior - both will sponsor gas if your environment is configured for AA on that chain. Only `sponsorGas: false` changes the default behavior to disable sponsorship. Learn more about gas sponsorship control in the [Account Abstraction guide](../../../resources/account-abstraction#controlling-gas-sponsorship-per-transaction). You may have a more advanced use case than simply sending tokens from your Portal wallet. Next, we will dive into how to build your own transaction and also how to sign it (without submitting it). # Sign a transaction Source: https://docs.portalhq.io/sdks/web/guide/sign-a-transaction Easily use our EIP-1193 provider or custom built functions to make sending and signing transactions easier! ## Signing ### Improving signing performance You can reduce signing latency for EVM transactions by enabling **presignatures**. The SDK will then pre-compute part of the MPC signing flow in the background and use that data when the user signs, without any change to your code. Enable it via the `usePresignatures` feature flag when creating your `Portal` instance. See [Feature flags](/sdks/web/guide/feature-flags#usepresignatures) for details. The `Portal` instance includes a number of helper functions to allow you to execute `Provider` requests without having to worry about encoding, request format, or what method to pass in your request. These helper functions allows you to easily sign and submit transactions, sign messages, and even sign typed data. ```typescript theme={null} const chainId = 'eip155:1' // Ethereum Mainnet const myAddress = await portal.getEip155Address() // Your Ethereum address const messageToSign = "0x48656c6c6f20576f726c64" // Message must be a hex string const signature = await portal.request({ chainId, method: 'eth_sign', params: [myAddress, messageToSign], signatureApprovalMemo: 'Optional memo shown during approval', // optional }) ``` ### Signature approval memo Clients can pass an optional **`signatureApprovalMemo`** so the user sees a short description during the approval flow. It is supported in: * **`portal.request()`** — add `signatureApprovalMemo` to any signing method (`eth_sendTransaction`, `personal_sign`, `sol_signAndSendTransaction`, etc.). * **`portal.sendAsset(chain, params)`** — include `signatureApprovalMemo` in the `params` object. See [Send tokens](./send-tokens#advanced-controlling-gas-sponsorship). * **`portal.rawSign(curve, param, options)`** — pass an optional third argument with the memo. **Example: request with memo** ```typescript theme={null} const txHash = await portal.request({ chainId: 'eip155:1', method: 'eth_sendTransaction', params: [transaction], signatureApprovalMemo: 'Send transaction', }) ``` **Example: rawSign with memo** ```typescript theme={null} const signature = await portal.rawSign( PortalCurve.SECP256K1, messageHex, { signatureApprovalMemo: 'Sign login challenge' } ) ``` ### Signing a User Operation If your client uses [Account Abstraction](../../../resources/account-abstraction), you can sign an [ERC-4337 User Operation](https://eips.ethereum.org/EIPS/eip-4337) using the `eth_signUserOperation` method. This signs the User Operation without submitting it on-chain, returning the signature directly. ```typescript theme={null} const chainId = 'eip155:11155111' // Ethereum Sepolia try { const address = await portal.getEip155Address() if (!address) { throw new Error('Wallet address not found') } const userOp = { sender: address, nonce: '0x0', callData: '0x', callGasLimit: '0x5208', verificationGasLimit: '0x5208', preVerificationGas: '0x5208', maxFeePerGas: '0x1', maxPriorityFeePerGas: '0x1', } const signature = await portal.request({ chainId, method: 'eth_signUserOperation', params: [userOp], }) console.log('✅ Signature:', signature) } catch (error) { console.error('❌ Failed to sign UserOperation:', error) } ``` **User Operation Parameters:** | Name | Type | Description | | ---------------------- | -------- | ----------------------------------------------------------------------------- | | `sender` | `String` | The address of the smart contract account | | `nonce` | `String` | Anti-replay parameter (hex-encoded) | | `callData` | `String` | The data to pass to the `sender` during the main execution call (hex-encoded) | | `callGasLimit` | `String` | Gas limit for the main execution call (hex-encoded) | | `verificationGasLimit` | `String` | Gas limit for the verification step (hex-encoded) | | `preVerificationGas` | `String` | Gas paid for pre-verification (hex-encoded) | | `maxFeePerGas` | `String` | Maximum fee per unit of gas (hex-encoded) | | `maxPriorityFeePerGas` | `String` | Maximum priority fee per unit of gas (hex-encoded) | You can also pass a signature approval memo and control gas sponsorship: ```typescript theme={null} const signature = await portal.request({ chainId: 'eip155:11155111', method: 'eth_signUserOperation', params: [userOp], signatureApprovalMemo: 'Approve UserOp', sponsorGas: true, }) ``` ## Raw Requests with the Provider ## Estimating Gas By default, Portal will estimate and populate the `gas` property in a `transaction` object if the property is undefined. To estimate the `gas` value manually use the `eth_estimateGas` RPC call and pass in your transaction as the parameter. ```javascript theme={null} const gas = await portal.request({ chainId: 'eip155:1', method: 'eth_estimateGas', params: [ // partial transaction to estimate gas for { from:"0xFROMADDRESS", to:"0xTOADDRESS", value:"0xVALUE", input:"HASH_OF_CONTRACT_CALL_DATA", // only needed if calling a smart contract } ] }) console.log(gas) // "0x5208" ``` And now you are signing transactions with Portal! 🙌 🚀 Next, we'll explore how to simulate a transaction so that you can create smoother experiences for your users. # Simulate a transaction Source: https://docs.portalhq.io/sdks/web/guide/simulate-a-transaction Before committing to a transaction, it's often useful to simulate its outcome to understand the potential changes and detect any errors preemptively. Portal provides the function **`portal.simulateTransaction`**, which gives you insights into what will happen upon executing the transaction. Here's how it works: ```typescript theme={null} const handleSimulateTransaction = async () => { // First, construct a transaction. const transaction = { to: '0xRecipientAddressHere', // {string} The recipient address. value: '0x10DE4A2A', // {?string} The value to be sent in Wei. data: undefined, // {?string} Data for the transaction (for contract interactions). maxFeePerGas: undefined, // {?string} Maximum fee per gas. maxPriorityFeePerGas: undefined, // {?string} Maximum priority fee per gas. gas: undefined, // {?string} The gas limit. gasPrice: undefined // {?string} Gas price in Wei. }; // Next, simulate the transaction. const chainId = 'eip155:1' const simulatedResult = await portal.simulateTransaction(chainId, transaction); // Finally, you can handle or display the simulation results as needed. if (simulatedResult.error) { console.error("Transaction Error:", simulatedResult.error.message); } else if (simulatedResult.requestError) { console.error("Request Error:", simulatedResult.requestError.message); } else { console.log("Simulated Transaction Results:", simulatedResult.changes); } } ``` This function will return a JSON object that breaks down the transaction simulation: * **changes**: An array detailing all the potential transaction outcomes. Each change has the following structure: * **amount**: The amount being transferred. * **assetType**: The type of asset being dealt with (`NATIVE` or `ERC20` or `ERC721` or `ERC1155` or `SPECIAL_NFT`). * **changeType**: The type of change (`APPROVE` or `TRANSFER`). * **contractAddress**: Address of the contract being interacted with (null for native transactions). * **decimals**: Decimals used in the asset. * **from**: The sender address. * **name**: The name of the asset. * **rawAmount**: The unformatted amount being transferred. * **symbol**: The asset's symbol. * **to**: The receiver's address. * **tokenId**: An identifier for tokens (null for assets that are not tokens). * **gasUsed**: The gas used by the transaction. * **error**: An object that contains the error message if the transaction would fail upon execution. * **requestError**: An object that contains the error message if there was an issue with the request, such as a malformed transaction argument being provided. By incorporating transaction simulations, you can provide your users with a preview of the transaction outcomes and preemptively detect and handle errors, ensuring a smoother user experience. And now you are simulating transactions with Portal! 🙌 🚀 Next, we'll explore how to back up the wallet for recovery if the user loses device access. # Using the EIP-1193 Provider Source: https://docs.portalhq.io/sdks/web/guide/using-the-eip-1193-provider portal.provider is compliant with the EIP-1193 specification and can be used with libraries like ethers.js and web3.js. This guide will walk you through using portal.provider with ethers.js. Here's a code snippet that sends a transaction with `ethers.js` backed by `portal.provider`. ```typescript theme={null} // update portal to use your chain of choice (use the numeric chain ID) portal.updateChain('11155111') // Sepolia // get ethers.js Signer backed by portal.provider const ethersProvider = new ethers.BrowserProvider(portal.provider) const ethersSigner = await ethersProvider.getSigner() // Send the transaction const tx = await ethersSigner.sendTransaction({ to: '
', value: ethers.parseEther('0.001'), }) // wait for the transaction to be mined const receipt = await tx.wait() ``` Let's break it down. First, `portal.updateChain` is used to bind `portal.provider` to a specific EVM chain. It takes the numeric chain ID as a string, for example `'11155111'` for Sepolia or `'1'` for Ethereum Mainnet. `portal.provider` is then supplied to [ethers.js `BrowserProvider`](https://docs.ethers.org/v6/api/providers/#BrowserProvider). The returned object is an [ethers.js `Provider`](https://docs.ethers.org/v6/api/providers/#Provider) and [supports read-only requests](https://docs.ethers.org/v6/getting-started/#starting-connecting) to the chain. To be able to send a transaction we need a [`Signer`](https://docs.ethers.org/v6/api/providers/#Signer) which `ethersProvider.getSigner` returns. The next bit of code is `ethers.js` specific and uses `ethersSigner.sendTransaction` to send 0.001 ETH to the address specified. The code also waits for the transaction to be mined. That's all! We've successfully used `portal.provider` and `ethers.js` to send a transaction! # Web authentication methods Source: https://docs.portalhq.io/sdks/web/guide/web-authentication-methods The Portal Web SDK offers various approaches to authenticating. The main reason for this is to allow you to strike the right balance between integration time and security. The methods on this page assume you have already created a client and are authenticating it. If you would rather have Portal identify your end users for you, with an email magic link, Google, or Apple, see [Authentication](/resources/authentication/overview). Portal creates the client as part of the sign-in, so no server-side call is needed for a user's first login. ## Authenticating with a Client API Key This approach requires the least setup, but is also the least secure as it exposes your Client API Key to your DOM. For this reason, we recommend that this method only be used during local development. The only thing required for this is to create a client like normal via the Portal REST API and use the `clientApiKey` for the `apiKey` option when initializing `Portal`. ```typescript theme={null} import Portal from '@portal-hq/web' const portal = new Portal({ apiKey: 'YOUR_CLIENT_API_KEY', rpcConfig: { 'eip155:1': 'YOUR-INFURA-OR-ALCHEMY-URL', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': 'https://api.mainnet-beta.solana.com', }, }) ``` ## Authenticating with a Web OTP This approach is a bit more secure as it instead exposes a OTP to your DOM and once this is used, it's invalidated on Portal's end, which makes it unusable for authentication in the future. The steps required to accomplish this are: 1. Make a `GET` request to `https://api.portalhq.io/api/v1/custodians/clients/${clientId}/web-otp` using your `CUSTODIAN_API_KEY` as a `Bearer` token. 2. Initialize `Portal` with the delivered OTP as the `authToken` initialization option. ### Fetching a Web OTP ```typescript theme={null} // On your server yourApp.get('/otp', (req: Request, res: Response) => { const url = `https://api.portalhq.io/api/v1/custodians/clients/${clientId}/web-otp` const { data } = await axios.get(url, { headers: { Authorization: `Bearer YOUR-CUSTODIAN-API-KEY`, }, }) res.send(data.otp) }) ``` ### Initializing Portal ```typescript theme={null} import Portal from '@portal-hq/web' const initializePortal = () => { const response = await fetch('https://yourserver.com/otp', { credentials: 'include' }) const authToken = response.text() const portal = new Portal({ authToken, rpcConfig: { 'eip155:1': 'YOUR-INFURA-OR-ALCHEMY-URL', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': 'https://api.mainnet-beta.solana.com', }, }) } ``` ## Authenticating with an authUrl This approach is by far the most secure as it never exposes any Portal credentials to your DOM. This works by adding a route to your application that fetches an OTP and redirects to Portal's server for validation. The URL to your authentication route is then provided as the `authUrl` option when initializing `Portal`. The steps required to accomplish this are: 1. Create a route on your server that gets a new Web OTP and redirects to `https://web.portalhq.io/clients/token/validate?otp=YOUR-WEB-OTP` 2. Enable CORS access to `https://web.portalhq.io` on your new route 3. Initialize `Portal` with your `authUrl` ### Creating an authentication route ```typescript theme={null} // On your server yourApp.get( '/portal/authenticate', // This can be any route you like cors({ credentials: true, origin: 'https://web.portalhq.io', }), async (req: Request, res: Response) => { const url = `https://api.portalhq.io/api/v1/custodians/clients/${clientId}/web-otp` const { data } = await axios.get(url, { headers: { Authorization: 'Bearer YOUR-CUSTODIAN-API-KEY' } }) // This redirect is where the magic happens res.redirect(`https://web.portalhq.io/clients/token/validate?otp=${data.otp}`) } ) ``` ### Initializing Portal with an authUrl ```typescript theme={null} import Portal from '@portal-hq/web' const portal = new Portal({ // This URL corresponds to the route you created in the previous step authUrl: 'https://yourserver.com/portal/authenticate', rpcConfig: { 'eip155:1': 'YOUR-INFURA-OR-ALCHEMY-URL', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': 'https://api.mainnet-beta.solana.com', }, }) ``` # Earn with Yield.xyz Source: https://docs.portalhq.io/sdks/web/guide/yield-xyz Learn how to discover, enter, manage, and exit yield opportunities. Portal's Web SDK provides comprehensive yield opportunities capabilities through the `portal.yield.yieldXyz` API. This guide covers discovering yield opportunities, entering positions, managing existing positions, and exiting yield opportunities. ## Overview The yield functionality allows users to: * **Discover** available yield opportunities across different protocols and networks * **Enter** yield positions by depositing tokens into yield opportunities * **Manage** existing positions (claim rewards, voting, etc.) * **Exit** yield positions to withdraw aggregated tokens and rewards * **Track** yield balances and historical yield actions * **Deposit and withdraw in one call** using the high-level `deposit` and `withdraw` helpers (sign, confirm between steps, and track with Yield.xyz) ## Prerequisites Before using yield operations, ensure you have: * A properly initialized Portal client * An active wallet with the required token(s) on the target network (see [Create a wallet](./create-a-wallet)) * Yield.xyz integration enabled in your Portal Dashboard (see [Yield.xyz Integration](../../../resources/integrations/yield-xyz)) ## Discovering Yield Opportunities Use the `discover` method to find available yield opportunities. For complete API documentation, see the [Yield.xyz API reference](https://docs.yield.xyz/reference/yieldscontroller_getyields). ```typescript theme={null} try { const discoverRequest: YieldXyzGetYieldsRequest = { limit: 10, offset: 0, network: 'eip155:11155111', // Sepolia network // ... other parameters }; const result = await portal.yield.yieldXyz.discover(discoverRequest); // manage Yield.xyz result console.log(result); } catch (err) { // Handle Yield.xyz discover error console.error(err); } ``` Popular, high-quality USDC yield options with no lockups or limits: * USDC Aave V3 Lending: * `base-usdc-aave-v3-lending` * USDC Fluid Vault: * `base-usdc-fusdc-0xf42f5795d9ac7e9d757db633d693cd548cfd9169-4626-vault` * USDC Spark Savings Vault: * `ethereum-usdc-spusdc-0x28b3a8fb53b741a8fd78c0fb9a6b2393d896a43d-4626-vault` ## Entering Yield Positions To enter a yield position, first discover the specific yield, then use the `enter` method. For complete API documentation, see the [Yield.xyz enter yield reference](https://docs.yield.xyz/reference/actionscontroller_enteryield). For the example below, we will use the yield opportunity with the ID `"ethereum-sepolia-link-aave-v3-lending"`. Fund your Portal client with the required `LINK` token to enter the position. ```typescript theme={null} try { const userAddress = await portal.getEip155Address(); // or getSolanaAddress() if using a Solana yield const enterRequest: YieldXyzEnterRequest = { yieldId: 'ethereum-sepolia-link-aave-v3-lending', address: userAddress, arguments: { amount: '1', // 1 LINK Token }, }; const enterResult = await portal.yield.yieldXyz.enter(enterRequest); const transactions = enterResult.data?.rawResponse?.transactions; // Process transactions, this is described in the "Transaction Processing" section below processTransactions(transactions); } catch (err) { // Handle Yield.xyz enter error console.error(err); } ``` ## Checking Yield Balances Retrieve current yield positions and balances. For complete API documentation, see the [Yield.xyz get balances reference](https://docs.yield.xyz/reference/yieldscontroller_getaggregatebalances). ```typescript theme={null} try { const address = await portal.getEip155Address(); // or getSolanaAddress() if using a Solana yield const getBalancesRequest: YieldXyzGetBalancesRequest = { queries: [ { address, network: 'eip155:11155111', }, ], }; const result = await portal.yield.yieldXyz.getBalances(getBalancesRequest); // Process and display yield positions information console.log(result); } catch (err) { // Handle Yield.xyz Get balances error console.error(err); } ``` We recommend always specifying a `yieldId` on each balance query. When `yieldId` is provided, Yield.xyz can resolve balances directly, so you don't need to call the `track` endpoint after entering or exiting positions. ```typescript theme={null} const getBalancesRequest: YieldXyzGetBalancesRequest = { queries: [ { address, network: 'eip155:11155111', yieldId: 'ethereum-sepolia-link-aave-v3-lending', }, ], }; ``` ## Exiting Yield Positions Use the `exit` method to withdraw from yield positions. For complete API documentation, see the [Yield.xyz exit yield reference](https://docs.yield.xyz/reference/actionscontroller_exityield). ```typescript theme={null} try { const address = await portal.getEip155Address(); // or getSolanaAddress() if using a Solana yield const exitRequest: YieldXyzExitRequest = { yieldId: exitYieldId, address, arguments: { amount: exitYieldAmount || '0.001', }, }; const result = await portal.yield.yieldXyz.exit(exitRequest); const transactions = result.data.rawResponse.transactions; // Process transactions, this is described in the "Transaction Processing" section below processTransactions(transactions); } catch (err) { // Handle Yield.xyz Exit error console.error(err); } ``` ## High-Level Methods Use `deposit` and `withdraw` when you want one call for the full flow: resolve the yield, build the action, sign and send each transaction in order, wait between steps when configured, and report hashes to Yield.xyz. Specify the position with **`yieldId`**, or with **`chain`** (CAIP-2) + **`token`** (must match your Portal yield defaults). Both methods require **`amount` and `address`**. Optionally set **`arguments`**. ### Signatures ```typescript theme={null} deposit( params: YieldDepositParams, options?: YieldSubmitOptions ): Promise withdraw( params: YieldWithdrawParams, options?: YieldSubmitOptions ): Promise ``` ### Essential parameters | Name | Required | Description | | -------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------- | | `yieldId` *or* `chain` + `token` | Yes | Non-empty `yieldId` wins over `chain` / `token`. On Web, `chain` must be a full CAIP-2 id (for example `eip155:11155111`). | | `amount` | Yes | Amount to deposit or withdraw. | | `address` | Yes | Wallet address for the action. | | `arguments` | No | Protocol-specific Yield.xyz arguments. | Second argument `YieldSubmitOptions`: | Name | Required | Description | | ------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `onProgress` | No | Callback fired at each step per transaction: `signing`, `submitted`, `confirming`, `confirmed`. | | `signAndSendTransaction` | No | Per-call signer. Receives the unsigned transaction payload and the network string; must return the submitted tx hash. Takes priority over the instance-level setter. When omitted, Portal's built-in MPC signer is used automatically. | | `waitForConfirmation` | No | Per-call confirmation override. Called after each broadcast with `(txHash, network)`. **MUST return `true` for confirmed success.** The behavior for each return value is:
• **`true`** → Transaction confirmed successfully. Execution continues to next step.
• **`false`** → Transaction failed on-chain (e.g., reverted with receipt status `0x0`). Execution stops immediately and returns partial results with `status: 'FAILED'`.
• **Throws or times out** → Execution stops safely and returns partial results with `status: 'PARTIAL_SUCCESS'` (if some transactions completed) or `status: 'FAILED'` (if none completed).

When omitted, `portal.waitForConfirmation` is used for supported EVM networks. | | `evmRequestFn` | No | **Web SDK:** JSON-RPC function used when building the default confirmation waiter for EVM (for example receipt polling via `eth_getTransactionReceipt`). | | `evmPollerOptions` | No | Tune the built-in EVM receipt poller (`pollIntervalMs`, `timeoutMs`) when the default confirmation path uses `evmRequestFn`. Has no effect when `waitForConfirmation` is provided. | **Strict confirmation semantics:** Yield operations use **strict confirmation**. Only `waitForConfirmation(...) === true` is considered success. **Confirmation outcomes:** * **Returns `true`:** Transaction confirmed successfully. Execution continues to next step. * **Returns `false`:** Transaction failed on-chain (e.g., reverted with `receipt.status === "0x0"`). Execution **stops immediately** and returns partial results with `status: 'FAILED'`. * **Throws or times out:** Execution **stops safely** and returns partial results with `status: 'PARTIAL_SUCCESS'` (if some transactions completed) or `status: 'FAILED'` (if none completed). **Status determination:** * `SUCCESS`: All required confirmations returned `true`, OR no confirmation waiter was configured * `PARTIAL_SUCCESS`: At least one confirmation succeeded, but execution stopped before completing all steps (e.g., timeout or later transaction failed) * `FAILED`: First transaction confirmation returned `false` or failed (zero confirmations reached) **Default timeout behavior:** * Timeout: `900_000ms` (15 minutes) * Poll interval: `4_000ms` This differs from Li.Fi and 0x `tradeAsset`, where any confirmation failure **throws** and aborts the entire operation — Yield's approach allows graceful recovery with partial progress when confirmations time out or are uncertain. ### Return value | Field | Description | | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `hashes` | Submitted transaction hashes, in order. Contains hashes for all transactions that were submitted, regardless of final outcome. | | `yieldId` | Resolved yield id. | | `status` | **Operation result status:**
• `SUCCESS` — All transactions confirmed successfully (or no `waitForConfirmation` configured).
• `PARTIAL_SUCCESS` — Some transactions confirmed, but execution stopped before completing all steps (e.g., timeout).
• `FAILED` — A transaction failed on-chain (receipt status `0x0`) or confirmation returned `false`. | | `chain` / `token` | Set when you passed `chain` + `token`. | | `yieldOpportunityDetails` | Action metadata from Yield.xyz. | ### Handling Results Always check the `status` field to determine the outcome: ```typescript theme={null} const result = await portal.yield.yieldXyz.deposit(params, options) switch (result.status) { case 'SUCCESS': console.log('All transactions confirmed:', result.hashes) // Safe to show success UI break case 'PARTIAL_SUCCESS': console.warn('Some transactions completed:', result.hashes) // Check yield balance to determine actual position state // May need user action to complete or retry remaining steps break case 'FAILED': console.error('Transaction failed on-chain:', result.hashes[result.hashes.length - 1]) // Show error UI, check transaction receipt for revert reason break } ``` **Important:** A non-empty `hashes` array does NOT guarantee success. Always check `status`. **Reverted transactions:** When an EVM transaction is reverted on-chain (`receipt.status === "0x0"`), the default `waitForConfirmation` implementation returns `false`, which causes the SDK to stop execution immediately and set `status: 'FAILED'`. This is the correct behavior — a reverted transaction is a definitive failure, not an uncertain state. If you implement a custom `waitForConfirmation`, ensure reverted transactions either: * Return `false` (recommended for graceful degradation in multi-step flows) * Throw an error (for explicit failures) **Never return `true` for a reverted transaction.** ### Example (`deposit` with progress and poller tuning) ```typescript theme={null} import Portal from '@portal-hq/web' const portal = new Portal({ apiKey: 'YOUR_PORTAL_CLIENT_API_KEY', rpcConfig: { 'eip155:11155111': 'https://YOUR_RPC_URL' }, }) async function depositWithProgress() { const address = await portal.getEip155Address() if (!address) throw new Error('No EVM address') const result = await portal.yield.yieldXyz.deposit( { chain: 'eip155:11155111', token: 'ETH', amount: '0.0000001', address, }, { onProgress: (e) => { console.log( `[deposit] ${e.step} step ${e.index + 1}/${e.total}`, e.hash ?? '', ) }, evmPollerOptions: { pollIntervalMs: 4000, timeoutMs: 300_000 }, }, ) console.log('Status:', result.status) console.log('Hashes:', result.hashes) // Always check status to determine outcome if (result.status === 'SUCCESS') { console.log('Deposit completed successfully!') } else if (result.status === 'PARTIAL_SUCCESS') { console.warn('Deposit partially completed. Check yield balance.') } else { console.error('Deposit failed on-chain.') } return result } ``` Use `withdraw` the same way with identical parameter shapes. ### Example (custom confirmation logic) ```typescript theme={null} const result = await portal.yield.yieldXyz.deposit( { chain: 'eip155:11155111', token: 'ETH', amount: '0.0000001', }, { waitForConfirmation: async (txHash, network) => { return portal.waitForConfirmation(txHash, network) }, }, ) ``` ### Example (custom signer) ```typescript theme={null} const result = await portal.yield.yieldXyz.deposit( { chain: 'eip155:11155111', token: 'ETH', amount: '0.0000001', }, { signAndSendTransaction: async (transaction, network) => { const txHash = await myCustomSigner.signAndSend(transaction, network) return txHash }, }, ) ``` ## Get Validators Use `getValidators()` to fetch the validator addresses for a specific `yieldId`.\ These addresses are used for token approval flows. ```typescript theme={null} getValidators(yieldId: string): Promise ``` ### Example ```typescript theme={null} import Portal from '@portal-hq/web' async function fetchValidators(portal: Portal) { try { const yieldId = 'monad-testnet-mon-native-staking' const validators = await portal.yield.getValidators(yieldId) console.log(`Validators for ${yieldId}:`, validators) } catch (error) { console.error('getValidators failed:', error) } } ``` The SDK throws an error if the response does not contain a valid validators array. ## Managing Yield Positions If your Portal client has entered into a yield balance, they may have a yield balance that has an available `pendingActions`. You can use the `manage` method to perform actions on existing yield positions. For example, if the balance has a `pendingAction` of `WITHDRAW` or `CLAIM_REWARDS`, you can use the `manage` method to withdraw or claim rewards from the yield balance. For complete API documentation, see the [Yield.xyz manage yield reference](https://docs.yield.xyz/reference/actionscontroller_manageyield). ```typescript theme={null} try { const address = await portal.getEip155Address(); // or getSolanaAddress() if using a Solana yield const manageRequest: YieldXyzManageYieldRequest = { yieldId, address, action: yieldAction as any, passthrough: yieldPassthrough, }; const result = await portal.yield.yieldXyz.manage(manageRequest); const transactions = result.data.rawResponse.transactions; // Process transactions, this is described in the "Transaction Processing" section below processTransactions(transactions); } catch (err) { // Handle Yield.xyz Manage error console.error(err); } ``` ## Getting Historical Actions Retrieve the history of yield actions for an address. For complete API documentation, see the [Yield.xyz get actions reference](https://docs.yield.xyz/reference/actionscontroller_getactions). ```typescript theme={null} try { const address = await portal.getEip155Address(); // or getSolanaAddress() if using a Solana yield const historicalActionsRequest: YieldXyzGetHistoricalActionsRequest = { address, limit: 10, offset: 0, }; const result = await portal.yield.yieldXyz.getHistoricalActions(historicalActionsRequest); // process historical actions result } catch (err) { // Handle Yield.xyz Historical Actions error console.error(err); } ``` ## Transaction processing (low-level `enter` / `exit` / `manage`) If you use **`deposit()` or `withdraw()`**, skip this section—the Web SDK already sequences transactions, waits between steps when configured, and calls Yield.xyz tracking. For **manual** flows that return raw transactions from `enter`, `exit`, or `manage`: process steps in order, and **wait for inclusion before sending the next transaction** (multi-step actions depend on prior txs mining; the high-level methods use `portal.waitForConfirmation` or your override between steps). For standard transaction-hash flows, prefer `portal.waitForConfirmation(txHash, network)` rather than writing your own receipt polling. If your signer returns a non-standard identifier — such as a user operation hash for account abstraction — you will need a custom wait loop against the appropriate receipt API (e.g., `eth_getUserOperationReceipt` for bundler-based AA wallets). After the wait step, call `portal.yield.yieldXyz.track({ transactionId, hash })`. `deposit()` / `withdraw()` strip stale planning **`nonce`** from JSON EVM `unsignedTransaction` values before signing. If you implement manual signing, refresh or omit `nonce` the same way the signer expects, or you may see nonce collisions across steps. For complete API documentation, see the [Yield.xyz submit transaction hash reference](https://docs.yield.xyz/reference/transactionscontroller_submittransactionhash) and [get transaction details reference](https://docs.yield.xyz/reference/transactionscontroller_gettransaction). For account abstraction enabled Portal clients, use `eth_getUserOperationReceipt` instead of `eth_getTransactionReceipt` to wait for confirmation, since signing returns a *user operation hash*, not a transaction hash. If you don't specify a `yieldId` on your balance queries, you'll need to call `track` after each transaction so Yield.xyz can attribute the position. Pass the **transaction hash** (extracted from `response.result.receipt.transactionHash` for AA clients), not the user operation hash. ```typescript theme={null} async function processTransactions(transactions?: YieldXyzActionTransaction[]): Promise { const sorted = transactions?.sort((a, b) => a.stepIndex - b.stepIndex) ?? []; for (const tx of sorted) { if (tx.unsignedTransaction != null && tx.status === 'CREATED') { const ok = await signAndSubmitAndConfirm(tx); if (!ok) break; } } } async function signAndSubmitAndConfirm(transaction: YieldXyzActionTransaction): Promise { const unsignedTxJson = transaction.unsignedTransaction; if (typeof unsignedTxJson !== 'string') return false; const txParams = JSON.parse(unsignedTxJson) as Record; const ethTransaction = { from: (txParams.from as string) ?? '', to: (txParams.to as string) ?? '', value: (txParams.value as string) ?? '0x0', data: (txParams.data as string) ?? '0x', gas: null, gasPrice: null, maxFeePerGas: null, maxPriorityFeePerGas: null, }; const txHash = await portal.request({ chainId: transaction.network, method: 'eth_sendTransaction', params: [ethTransaction], }); if (!txHash) return false; // IMPORTANT: Check the confirmation result const confirmed = await portal.waitForConfirmation(txHash, transaction.network) // Track the transaction with Yield.xyz await portal.yield.yieldXyz.track({ transactionId: transaction.id, hash: txHash, }) // Return confirmation status - false will stop multi-step flows safely return confirmed } ``` ## Best Practices 1. **Always check the `status` field** in deposit/withdraw results to determine operation outcome (`SUCCESS`, `PARTIAL_SUCCESS`, or `FAILED`) 2. **Always check yield availability** before attempting to enter positions 3. **Prefer `deposit()` / `withdraw()`** when you want Portal to handle signing, confirmation between steps, and tracking—avoid copying receipt-poll loops from older examples 4. **Process low-level transactions sequentially** when using `enter` / `exit` / `manage` directly; later steps depend on earlier transactions being mined 5. **Handle network errors gracefully** and provide user feedback 6. **Monitor transaction status** and provide progress updates to users (`onProgress` on high-level methods) 7. **Validate user balances** before initiating yield operations 8. **Check for pending actions** in balances before calling the manage method 9. **Test on testnets first** (for example Sepolia) before moving to mainnet ## Supported Networks The yield functionality supports various networks including: * Monad (`eip155:143`) * Monad Testnet (`eip155:10143`) * Arbitrum (`eip155:42161`) * Avalanche C (`eip155:43114`) * Base (`eip155:8453`) * Base Sepolia (`eip155:84532`) * Celo (`eip155:42220`) * Core (`eip155:1116`) * Ethereum (`eip155:1`) * Ethereum Sepolia (`eip155:11155111`) * Fantom (`eip155:250`) * Gnosis (`eip155:100`) * Harmony (`eip155:1666600000`) * Hyperevm (`eip155:999`) * Katana (`eip155:747474`) * Linea (`eip155:59144`) * Moonriver (`eip155:1285`) * Optimism (`eip155:10`) * Optimism Sepolia (`eip155:11155420`) * Plasma (`eip155:9745`) * Polygon (`eip155:137`) * Polygon Amoy (`eip155:80002`) * Sonic (`eip155:146`) * Unichain (`eip155:130`) * Viction (`eip155:88`) * zkSync (`eip155:324`) * Solana (`solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`) * Solana Devnet (`solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`) * Stellar (`stellar:pubnet`) * Stellar Testnet (`stellar:testnet`) * Tron (`tron:mainnet`) ## Next Steps * Learn about [managing wallet lifecycle states](./manage-wallet-lifecycle-states) * Explore [transaction simulation](./simulate-a-transaction) * Check out [Portal API methods](./portal-api-methods) # Swap Tokens with 0x Source: https://docs.portalhq.io/sdks/web/guide/zero-x Learn how to swap tokens using Portal's Web SDK with 0x integration. Portal's Web SDK provides high-level 0x swaps through `portal.trading.zeroX.tradeAsset(...)` and lower-level access through `portal.trading.zeroX`. ## Overview Using the 0x integration, you can: * **Trade assets end-to-end** with one high-level call * **Track lifecycle progress** from quote fetch to confirmation * **Override signer and confirmation behavior** per call when needed * Fetch **indicative prices** for token swaps * Fetch **swap quotes** between supported tokens * Retrieve **available liquidity sources** * **Execute swaps manually** by signing and submitting transactions from a quote ## Prerequisites Before using the 0x API, make sure you have: * A properly initialized Portal client * An active wallet with sufficient balance on the source network\ (see [Create a wallet](./create-a-wallet)) * 0x integration enabled in your Portal Dashboard (see [0x Integration](../../../integrations/Trading/zerox)) OR have a 0x API Key available ## High-Level Methods ### `tradeAsset` Fetches a 0x quote, signs and broadcasts the quote transaction, waits for on-chain confirmation, and returns hashes. ### Signature ```typescript theme={null} tradeAsset( params: ZeroXTradeAssetParams, options?: ZeroXTradeAssetOptions & ZeroExOptions, ): Promise ``` **Essential parameters** | Name | Required | Description | | ------------------------------------------------ | -------- | --------------------------------------------------------------------------------------------- | | `chainId` | Yes | CAIP-2 chain (e.g. `'eip155:1'`). See [Supported Networks](#supported-networks). | | `sellToken` | Yes | Token to sell (symbol or address). | | `buyToken` | Yes | Token to buy (symbol or address). | | `sellAmount` | Yes | Amount in base units (smallest units). | | `fromAddress` | Yes | Sender address used by high-level 0x execution (**use this field, not `txOrigin`**). | | `slippageBps` | No | Max slippage in basis points (e.g. `100` = 1%). | | `swapFeeRecipient`, `swapFeeBps`, `swapFeeToken` | No | Integrator fee fields. | | `tradeSurplusRecipient` | No | Surplus recipient. | | `gasPrice` | No | Optional gas price. | | `excludedSources` | No | Comma-separated sources to exclude. | | `sellEntireBalance` | No | `'true'` / `'false'`. | | `onProgress` | No | Stages such as `fetching_quote`, `signing`, `submitted`, `confirming`, `confirmed`, `failed`. | | `zeroXApiKey` | No | Override the Dashboard key for this call. | `ZeroXTradeAssetOptions`: | Name | Required | Description | | ------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `signAndSendTransaction` | No | Per-call signer override. | | `waitForConfirmation` | No | Per-call confirmation override. **MUST return `true` or resolve for success.** Returning `false`, throwing an error, or timing out will **abort the swap** and throw an error. | | `evmRequestFn` | No | **Web SDK:** When `waitForConfirmation` is omitted, receipt polling can use this JSON-RPC function (e.g. `eth_getTransactionReceipt`) on EVM chains. | | `evmPollerOptions` | No | Tune the built-in EVM receipt poller when using `evmRequestFn` (`pollIntervalMs`, `timeoutMs`). | | *(Inherited from `ZeroExOptions`)* `zeroXApiKey` | No | Per-call API key (overrides Dashboard key). | **Return value** | Field | Description | | -------- | ----------------------------- | | `hashes` | Broadcast transaction hashes. | 0x `tradeAsset` execution precedence: * **Signer:** per-call `options.signAndSendTransaction` → instance default signer → throw * **Confirmation:** per-call `options.waitForConfirmation` → instance default waiter, **or** on Web, `evmRequestFn` + receipt polling when `waitForConfirmation` is omitted Confirmation is strict: if the waiter returns `false`, times out, throws an error, or the network is unsupported, the swap fails immediately and throws. On **portal-react-native**, `tradeAsset` does not offer `evmRequestFn`; you supply `waitForConfirmation` (or rely on the Portal default where wired). `tradeAsset` requires **at least one** confirmation mechanism. If neither `waitForConfirmation` nor `evmRequestFn` is available (from instance defaults or per-call options), the method throws immediately: `[ZeroX] tradeAsset requires waitForConfirmation (instance default or per-call option), or evmRequestFn fallback.` When you construct `Portal` with an `rpcConfig`, `evmRequestFn` is wired automatically — no extra setup needed. This requirement only surfaces when using `ZeroX` standalone or without Portal's default wiring. Receipt-polling timeouts are **strict**: if confirmation times out, the swap throws and does not return a result. **Example (`onProgress` + default on-chain wait)** ```typescript theme={null} import Portal from '@portal-hq/web' import type { ZeroXTradeAssetParams } from '@portal-hq/web' const portal = new Portal({ apiKey: 'YOUR_PORTAL_CLIENT_API_KEY', rpcConfig: { 'eip155:1': 'https://YOUR_RPC_URL', }, }) async function swap() { const eip155Address = await portal.getEip155Address() if (!eip155Address) throw new Error('No EVM address') const params: ZeroXTradeAssetParams = { chainId: 'eip155:1', sellToken: 'ETH', buyToken: 'USDC', sellAmount: '100000000000000', fromAddress: eip155Address, onProgress: (status, data) => { console.log('[0x]', status, data?.txHash ?? '', data?.errorMessage ?? '') }, } try { const result = await portal.trading.zeroX.tradeAsset(params) console.log('Hashes:', result.hashes) return result } catch (e) { console.error('tradeAsset failed', e) throw e } } ``` Use `try/catch` around `tradeAsset`; `onProgress` may emit `failed` before the thrown error. *** ## Example (custom API key + execution options) ```typescript theme={null} const eip155Address = await portal.getEip155Address() if (!eip155Address) throw new Error('No EVM address') const result = await portal.trading.zeroX.tradeAsset( { chainId: 'eip155:1', sellToken: 'ETH', buyToken: 'USDC', sellAmount: '100000000000000', fromAddress: eip155Address, zeroXApiKey: 'YOUR_0X_API_KEY', onProgress: (status, data) => { console.log('[0x]', status, data?.txHash ?? '', data?.errorMessage ?? '') }, }, { waitForConfirmation: async (txHash, network) => { return portal.waitForConfirmation(txHash, network) }, }, ) console.log(result.hashes) ``` *** ## Low-level methods The following methods return quotes, prices, or raw transaction data for you to sign with `portal.request` when you need full control over each step. ### Using a Custom 0x API Key (Optional) By default, Portal uses the 0x API Key that can be added through the Portal Dashboard to communicate with the 0x integration. If you have a 0x API key that you want to test with locally, you can optionally include it in the request body via the options parameter. ```typescript theme={null} await portal.trading.zeroX.getQuote(request) ``` ```typescript theme={null} await portal.trading.zeroX.getQuote(request, { zeroXApiKey: 'YOUR_0X_API_KEY', }) ``` ### Getting a Price (Indicative) Use `portal.trading.zeroX.getPrice` to retrieve an **indicative price** for a token swap without generating executable transaction data. This method is useful for displaying prices, estimating swap outcomes, or building preview experiences without committing to a quote. ```typescript theme={null} async function getZeroXPrice(portal: Portal) { const request: ZeroExPriceRequest = { chainId: 'eip155:8453', // BASE network sellToken: 'ETH', buyToken: 'USDC', sellAmount: '10000000000000', // 0.00001 ETH } const response = await portal.trading.zeroX.getPrice(request) const price = response.data.rawResponse } ``` ### Getting a Swap Quote Use `portal.trading.zeroX.getQuote` to fetch a swap quote from 0x. ```typescript theme={null} async function getZeroXQuote(portal: Portal) { const takerAddress = await portal.getEip155Address() const request: ZeroExQuoteRequest = { chainId: 'eip155:8453', // BASE network sellToken: 'ETH', buyToken: 'USDC', sellAmount: '10000000000000', // 0.00001 ETH } const response = await portal.trading.zeroX.getQuote(request) const quote = response.data.rawResponse console.log(quote) } ``` ### Getting Liquidity Sources You can query available liquidity sources supported by 0x using `portal.trading.zeroX.getSources`. For full request and response details, refer to the Client API documentation. ```typescript theme={null} async function getZeroXSources(portal: Portal) { const response = await portal.trading.zeroX.getSources('eip155:1') const sources = response.data.rawResponse } ``` ### Low-level: Executing the Swap Once you receive a quote containing transaction data, execute the swap by sending the transaction through `portal.request`. > **Note**\ > The transaction data returned by 0x may include gas parameters such as > `gas` or `gasPrice`. These fields are optional — you can omit them and let > Portal estimate gas automatically, or include them if you prefer to use > 0x's suggested values. ```typescript theme={null} async function executeSwap( portal: Portal, fromAddress: string, quoteResponse: ZeroExQuoteResponse, chainId: string ) { const ethTx: ETHTransactionParam = { from: fromAddress, to: quoteResponse.data?.rawResponse.transaction.to, value: quoteResponse.data?.rawResponse.transaction.value ?? '0x0', data: quoteResponse.data?.rawResponse.transaction.data, } const response = await portal.request({ chainId, method: 'eth_sendTransaction', params: [ethTx] }) } ``` ## Supported Networks The `portal.trading.zeroX` API supports a predefined set of EIP-155 networks. Requests using unsupported chains will fail. | Network | EIP-155 Chain ID | | ---------- | ---------------- | | Ethereum | `eip155:1` | | Optimism | `eip155:10` | | BSC | `eip155:56` | | Unichain | `eip155:130` | | Polygon | `eip155:137` | | Worldchain | `eip155:480` | | Mantle | `eip155:5000` | | Base | `eip155:8453` | | Monad | `eip155:143` | | Mode | `eip155:34443` | | Arbitrum | `eip155:42161` | | Avalanche | `eip155:43114` | | Ink | `eip155:57073` | | Linea | `eip155:59144` | | Berachain | `eip155:80094` | | Blast | `eip155:81457` | | Scroll | `eip155:534352` | 0x supports mainnet networks only — calls against testnets surface as a `ZEROX_GET_QUOTE_ERROR` from the SDK. ## Next Steps * Learn how to [sign Ethereum transactions](./sign-a-transaction) * Explore how to [send tokens](./send-tokens) * Review the [Client API 0x endpoints](../../../apis/client/manual-reference#0x-integration) # Reference Source: https://docs.portalhq.io/sdks/web/reference Read the reference documentation for the Portal Web SDK. ## Portal Class The `Portal` class is the main entry point for the Portal Web SDK. It provides methods for wallet management, transaction signing, and blockchain interactions. ### Constructor Creates a new Portal instance with the specified configuration. ```typescript theme={null} constructor(options: PortalOptions) ``` **Parameters** | Name | Type | Required | Default | Description | | ---------------------- | --------------- | -------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `options.rpcConfig` | `RpcConfig` | No | Auto-generated for 13 chains | RPC endpoint map (CAIP-2 chain ID → URL). When omitted or `{}`, the SDK auto-generates gateway URLs for 13 built-in chains pointing at the Portal RPC gateway. An explicit non-empty map is used verbatim — no merging with defaults. | | `options.gatewayHost` | `string` | No | Derived from `host` | Portal RPC gateway hostname, used when auto-generating `rpcConfig`. Resolution order: `gatewayHost` → `host` → `'web.portalhq.io'`. Only needed when the gateway lives on a different hostname than `host` (e.g. a custom CDN). For standard Portal environments, setting `host` is sufficient. | | `options.apiKey` | `string` | No | - | Portal API key for authentication | | `options.authToken` | `string` | No | - | Authentication token for Portal services | | `options.authUrl` | `string` | No | - | Custom authentication URL | | `options.autoApprove` | `boolean` | No | `false` | Automatically approve transactions without user confirmation | | `options.gdrive` | `GDriveConfig` | No | - | Google Drive backup configuration with `clientId` | | `options.passkey` | `PasskeyConfig` | No | - | Passkey configuration for WebAuthn backup | | `options.host` | `string` | No | `'web.portalhq.io'` | Portal host URL | | `options.mpcVersion` | `string` | No | `'v6'` | MPC protocol version | | `options.mpcHost` | `string` | No | `'mpc-client.portalhq.io'` | MPC service host | | `options.featureFlags` | `FeatureFlags` | No | `{}` | Feature flags configuration | | `options.chainId` | `string` | No | - | Default chain ID for the provider | | `options.logLevel` | `LogLevel` | No | `'none'` | Logging level: `'none'`, `'error'`, `'warn'`, `'info'`, or `'debug'`. See [Logging Configuration](/sdks/web/guide/logging) | | `options.logger` | `ILogger` | No | `console` | Custom logger implementing `error`, `warn`, `info`, and `debug` methods | **Example Usage** ```typescript theme={null} import Portal, { buildDefaultRpcConfig } from '@portal-hq/web'; // Zero-config: SDK auto-generates RPC URLs for 13 built-in chains // via the Portal-managed gateway (web.portalhq.io). const portal = new Portal({ apiKey: 'your-api-key', }); // Custom subdomain: set `host` once — RPC gateway derives automatically. const portalCustom = new Portal({ apiKey: 'your-api-key', host: 'YOUR-CUSTOM-SUBDOMAIN', }); // Extended config: start from the default 13 chains and add extras. // Use this when your app needs chains not in the built-in set (e.g. Tron, AVAX Fuji). const portalExtended = new Portal({ apiKey: 'your-api-key', rpcConfig: { ...buildDefaultRpcConfig('web.portalhq.io'), 'tron:mainnet': 'https://web.portalhq.io/rpc/v1/tron/mainnet', }, gdrive: { clientId: 'your-client-id', }, }); ``` ### Public Properties | Property | Type | Description | | --------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `address` | `string \| undefined` | The current wallet address | | `apiKey` | `string \| undefined` | Portal API key | | `authToken` | `string \| undefined` | Authentication token | | `authUrl` | `string \| undefined` | Authentication URL | | `autoApprove` | `boolean` | Auto-approve transactions setting | | `gDriveConfig` | `GDriveConfig \| undefined` | Google Drive configuration | | `passkeyConfig` | `PasskeyConfig \| undefined` | Passkey configuration | | `host` | `string` | Portal host URL | | `mpc` | `Mpc` | MPC instance for wallet operations | | `yield` | `Yield` | [Yield.xyz integration](/integrations/Yield/yield-xyz) instance | | `trading` | `Trading` | [LiFi trading integration](/integrations/Trading/lifi) instance | | `ramps` | `Ramps` | Fiat on/off-ramp integrations: [Noah](/integrations/On-Off-Ramp/noah) (`portal.ramps.noah`) and [Meld](/integrations/On-Off-Ramp/meld) (`portal.ramps.meld`) | | `delegations` | `Delegations` | [Token delegations](/sdks/web/guide/delegations) (`portal.delegations`) | | `mpcHost` | `string` | MPC service host | | `mpcVersion` | `string` | MPC protocol version | | `provider` | `Provider` | Provider instance for RPC requests | | `featureFlags` | `FeatureFlags` | Feature flags configuration | | `ready` | `boolean` | Whether the Portal instance is ready (read-only getter) | ### Feature flags The `featureFlags` option controls optional SDK behavior. Pass it when creating a `Portal` instance. | Property | Type | Default | Description | | ----------------------- | --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `usePresignatures` | `boolean` | `false` | When `true`, the SDK uses presignatures to improve signing latency. The SDK generates and consumes presignatures in the background; you do not call presign APIs directly. Applies to SECP256K1 (EVM) signing only. See [Feature flags](/sdks/web/guide/feature-flags#usepresignatures) for details. | | `usePreGeneratedWallet` | `boolean` | `false` | When `true`, `createWallet` attempts to claim a pre-generated wallet share for faster wallet creation, automatically falling back to standard generation on an HTTP 5xx response from the enclave. See [Feature flags](/sdks/web/guide/feature-flags#usepregeneratedwallet) for details. | **Example** ```typescript theme={null} const portal = new Portal({ apiKey: 'your-api-key', featureFlags: { usePresignatures: true, }, }) ``` *** ## Logging Methods ### setLogLevel Sets the SDK log level at runtime. All SDK messages (e.g. deprecation warnings, provider messages) respect this level. ```typescript theme={null} public setLogLevel(level: LogLevel): void ``` **Parameters** | Name | Type | Description | | ------- | ---------- | ------------------------------------------------------------ | | `level` | `LogLevel` | One of `'none'`, `'error'`, `'warn'`, `'info'`, or `'debug'` | **Example Usage** ```typescript theme={null} portal.setLogLevel('debug') // Enable verbose logging portal.setLogLevel('none') // Disable all SDK logs ``` See [Logging Configuration](/sdks/web/guide/logging) for full details. ### getLogLevel Returns the current SDK log level. ```typescript theme={null} public getLogLevel(): LogLevel ``` **Returns** `LogLevel` — The current level: `'none' | 'error' | 'warn' | 'info' | 'debug'` **Example Usage** ```typescript theme={null} const level = portal.getLogLevel() console.log('Current log level:', level) ``` *** ## Initialization Methods ### onReady Registers a callback to be executed when the Portal instance is ready. ```typescript theme={null} public onReady(callback: () => any | Promise): () => void ``` **Parameters** | Name | Type | Description | | ---------- | --------------------------- | ------------------------------ | | `callback` | `() => any \| Promise` | Function to execute when ready | **Returns** A cleanup function that removes the callback when called. **Example Usage** ```typescript theme={null} const unsubscribe = portal.onReady(() => { console.log('Portal is ready!'); }); // Later, to remove the callback: unsubscribe(); ``` ### onInitializationError Registers a callback to be executed if initialization fails. ```typescript theme={null} public onInitializationError(callback: (reason: string) => any | Promise): () => void ``` **Parameters** | Name | Type | Description | | ---------- | ----------------------------------------- | --------------------------------------------------- | | `callback` | `(reason: string) => any \| Promise` | Function to execute on error, receives error reason | **Returns** A cleanup function that removes the callback when called. **Example Usage** ```typescript theme={null} const unsubscribe = portal.onInitializationError(reason => { console.error('Initialization failed:', reason); }); ``` ### onWalletNotOnDevice Registers a callback to be executed when the user's wallet signing share is no longer in device storage (for example, after Safari ITP clears `localStorage`). The callback is replayed immediately if the event already fired before this method was called, so it is safe to register at any point during initialization. ```typescript theme={null} public onWalletNotOnDevice( callback: (payload: WalletNotOnDevicePayload) => any | Promise, ): () => void ``` **Parameters** | Name | Type | Description | | ---------- | ------------------------------------------------------------ | ---------------------------------------------------- | | `callback` | `(payload: WalletNotOnDevicePayload) => any \| Promise` | Function to execute when the wallet is not on device | **Returns** A cleanup function that removes the callback when called. **Payload — `WalletNotOnDevicePayload`** | Field | Type | Description | | ------------ | ------------------- | --------------------------------------------------- | | `clientId` | `string` | The client ID of the affected wallet | | `isBackedUp` | `boolean` | Whether a backup exists that can be used to recover | | `reason` | `'storage_cleared'` | Why the wallet is not on device | **Example Usage** ```typescript theme={null} const unsubscribe = portal.onWalletNotOnDevice(async (payload) => { if (payload.isBackedUp) { // prompt user to recover their wallet } else { // no backup — clear state and restart onboarding await portal.clearLocalWallet() } }); ``` *** ## Wallet Lifecycle Methods ### createWallet Creates a new wallet and generates addresses for supported chains. ```typescript theme={null} public async createWallet(progress?: ProgressCallback): Promise ``` **Parameters** | Name | Type | Description | | ---------- | ------------------ | ---------------------------------------------------------------- | | `progress` | `ProgressCallback` | Optional callback for tracking progress with `MpcStatus` updates | **Returns** `Promise` - The wallet address **Example Usage** ```typescript theme={null} const address = await portal.createWallet(status => { console.log('Status:', status); }); console.log('Wallet created with address:', address); ``` ### configureFirebaseStorage Registers Firebase Authentication for wallet backup and recovery. The Web SDK runs MPC inside an iframe; your parent page implements `getToken`, and the iframe requests Firebase ID tokens over a `postMessage` bridge when storing or reading encryption keys on Portal’s token backup service (TBS). Call this method before `backupWallet` or `recoverWallet` with `BackupMethods.firebase`. ```typescript theme={null} public configureFirebaseStorage(options: FirebaseStorageConfigOptions): void ``` **Parameters** | Name | Type | Required | Description | | ------------------ | ------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `options.getToken` | `(options?: { forceRefresh?: boolean }) => Promise` | Yes | Returns a Firebase **ID token** for the signed-in user, or `null` if there is no user. When `options.forceRefresh` is `true`, request a refreshed token (used internally after HTTP `401` from TBS). | | `options.tbsHost` | `string` | No | TBS hostname or full origin (for example `backup.web.portalhq.io`). Defaults to `backup.web.portalhq.io`. | **`FirebaseStorageConfigOptions`** ```typescript theme={null} import type { FirebaseStorageConfigOptions } from '@portal-hq/web/types' ``` **Example usage** ```typescript theme={null} import Portal, { BackupMethods } from '@portal-hq/web' import { getAuth } from 'firebase/auth' const portal = new Portal({ apiKey: 'YOUR_PORTAL_CLIENT_API_KEY', }) portal.configureFirebaseStorage({ getToken: async (options?: { forceRefresh?: boolean }) => { const user = getAuth().currentUser if (!user) { return null } return user.getIdToken(Boolean(options?.forceRefresh)) }, }) await portal.backupWallet(BackupMethods.firebase) ``` **Errors and behavior** * If `getToken` is not configured and the iframe requests a token, the bridge rejects with **`Firebase storage is not configured (getToken missing)`**. * If `getToken` returns `null`, backup/recovery fails with messages such as **`[FirebaseStorage] Firebase ID token is required`** or **`[FirebaseStorage] Firebase ID token is null`**. * After HTTP `401`, the iframe retries once with `getToken({ forceRefresh: true })`. If that still returns `null`, you may see **`[FirebaseStorage] Firebase ID token is null after 401 retry`**. * TBS HTTP failures surface as **`[FirebaseStorage] Failed to store encryption key: …`** or **`[FirebaseStorage] Failed to read encryption key: …`**. Firebase ID tokens are sent as the `X-Firebase-Token` header together with your Portal Client API key (`Authorization: Bearer …` from the iframe’s configured `apiKey`). `validateOperations` on the iframe side only checks that a Firebase ID token is available; it does not verify TBS connectivity. *** ### backupWallet Creates a backup of the wallet using the specified backup method. ```typescript theme={null} public async backupWallet( backupMethod: BackupMethods, progress?: ProgressCallback, backupConfigs?: BackupConfigs ): Promise ``` **Parameters** | Name | Type | Description | | --------------- | ------------------ | ------------------------------------------------------------- | | `backupMethod` | `BackupMethods` | Backup method: `GDRIVE`, `PASSWORD`, `PASSKEY`, or `FIREBASE` | | `progress` | `ProgressCallback` | Optional progress callback | | `backupConfigs` | `BackupConfigs` | Optional backup configuration (e.g., password) | **Returns** `Promise` containing: * `cipherText`: Encrypted backup data * `storageCallback`: Function to finalize storage **Example Usage** ```typescript theme={null} import { getAuth } from 'firebase/auth' // Password backup const passwordBackup = await portal.backupWallet(BackupMethods.password, status => console.log(status), { passwordStorage: { password: 'your-password' }, }); await passwordBackup.storageCallback(); console.log('Password backup cipher text:', passwordBackup.cipherText); // Firebase backup (call configureFirebaseStorage first) portal.configureFirebaseStorage({ getToken: async (options?: { forceRefresh?: boolean }) => { const user = getAuth().currentUser if (!user) { return null } return user.getIdToken(Boolean(options?.forceRefresh)) }, }) const firebaseBackup = await portal.backupWallet(BackupMethods.firebase, status => console.log(status), ) await firebaseBackup.storageCallback(); console.log('Firebase backup cipher text:', firebaseBackup.cipherText); ``` ### recoverWallet Recovers a wallet from a backup. ```typescript theme={null} public async recoverWallet( cipherText: string, backupMethod: BackupMethods, backupConfigs?: BackupConfigs, progress?: ProgressCallback ): Promise ``` **Parameters** | Name | Type | Description | | --------------- | ------------------ | ----------------------------- | | `cipherText` | `string` | Encrypted backup data | | `backupMethod` | `BackupMethods` | Backup method used | | `backupConfigs` | `BackupConfigs` | Optional backup configuration | | `progress` | `ProgressCallback` | Optional progress callback | **Returns** `Promise` - The recovered wallet address **Example Usage** ```typescript theme={null} const address = await portal.recoverWallet('your-cipher-text', BackupMethods.password, { passwordStorage: { password: 'your-password' }, }); console.log('Wallet recovered:', address); ``` ### provisionWallet Alias for `recoverWallet`. Provisions a wallet from a backup. ```typescript theme={null} public async provisionWallet( cipherText: string, backupMethod: BackupMethods, backupConfigs: BackupConfigs, progress?: ProgressCallback ): Promise ``` ### clearLocalWallet Clears the local wallet data from device storage. ```typescript theme={null} public async clearLocalWallet(): Promise ``` **Returns** `Promise` - `true` if successful **Example Usage** ```typescript theme={null} const cleared = await portal.clearLocalWallet(); console.log('Wallet cleared:', cleared); ``` *** ## Wallet Status Methods ### doesWalletExist Checks if a wallet exists for the client. ```typescript theme={null} public async doesWalletExist(chainId?: string): Promise ``` **Parameters** | Name | Type | Description | | --------- | -------- | ------------------------------------------------------------------ | | `chainId` | `string` | Optional chain ID to check specific namespace (e.g., `'eip155:1'`) | **Returns** `Promise` - `true` if wallet exists **Example Usage** ```typescript theme={null} // Check if any wallet exists const exists = await portal.doesWalletExist(); // Check for specific chain const hasEthWallet = await portal.doesWalletExist('eip155:1'); ``` ### isWalletOnDevice Checks if wallet shares are stored on the current device. ```typescript theme={null} public async isWalletOnDevice(chainId?: string): Promise ``` **Parameters** | Name | Type | Description | | --------- | -------- | ----------------------------------------- | | `chainId` | `string` | Optional chain ID to check specific curve | **Returns** `Promise` - `true` if wallet is on device ### isWalletBackedUp Checks if the wallet has been backed up. ```typescript theme={null} public async isWalletBackedUp(chainId?: string): Promise ``` **Parameters** | Name | Type | Description | | --------- | -------- | ------------------------------------------ | | `chainId` | `string` | Optional chain ID to check specific wallet | **Returns** `Promise` - `true` if wallet is backed up ### isWalletRecoverable Checks if the wallet can be recovered. ```typescript theme={null} public async isWalletRecoverable(chainId?: string): Promise ``` **Returns** `Promise` - `true` if wallet is recoverable ### availableRecoveryMethods Gets the available recovery methods for the wallet. ```typescript theme={null} public async availableRecoveryMethods(chainId?: string): Promise ``` **Returns** `Promise` - Array of available backup methods **Example Usage** ```typescript theme={null} const methods = await portal.availableRecoveryMethods(); console.log('Available recovery methods:', methods); // e.g., ['GDRIVE', 'PASSWORD'] ``` *** ## Address Methods ### getEip155Address Gets the EIP-155 (Ethereum-compatible) address. ```typescript theme={null} public async getEip155Address(): Promise ``` **Returns** `Promise` - The Ethereum address **Example Usage** ```typescript theme={null} const ethAddress = await portal.getEip155Address(); console.log('Ethereum address:', ethAddress); ``` ### getSolanaAddress Gets the Solana address. ```typescript theme={null} public async getSolanaAddress(): Promise ``` **Returns** `Promise` - The Solana address **Example Usage** ```typescript theme={null} const solAddress = await portal.getSolanaAddress(); console.log('Solana address:', solAddress); ``` ### getTronAddress Gets the TRON address. ```typescript theme={null} public async getTronAddress(): Promise ``` **Returns** `Promise` - The TRON address, or an empty string if no TRON wallet exists yet **Example Usage** ```typescript theme={null} const tronAddress = await portal.getTronAddress(); console.log('TRON address:', tronAddress); ``` *** ## Eject Methods Providing the custodian backup share to the client device puts both MPC shares on a single device, removing the multi-party security benefits of MPC. This operation should only be done for users who want to move off of MPC and into a single private key. Use portal.eject() or portal.ejectPrivateKeys() at your own risk! ### eject Ejects the SECP256K1 private key from MPC custody. For eject examples see [here](./guide/eject-a-wallet) ```typescript theme={null} public async eject( backupMethod: BackupMethods, backupConfigs: BackupConfigs, orgBackupShare?: string, clientBackupCipherText?: string ): Promise ``` **Parameters** | Name | Type | Description | | ------------------------ | --------------- | --------------------------------------------------------------- | | `backupMethod` | `BackupMethods` | Backup method to use | | `backupConfigs` | `BackupConfigs` | Backup configuration | | `orgBackupShare` | `string` | Organization backup share (required if not using Portal backup) | | `clientBackupCipherText` | `string` | Client backup cipher text (required if not using Portal backup) | **Returns** `Promise` containing: * `SECP256K1`: The private key as a string ### ejectPrivateKeys Ejects all private keys (both SECP256K1 and ED25519) from MPC custody. ```typescript theme={null} public async ejectPrivateKeys( backupMethod: BackupMethods, backupConfigs: BackupConfigs, orgBackupShares: OrgBackupShares, clientBackupCipherText?: string ): Promise ``` **Parameters** | Name | Type | Description | | ------------------------ | ----------------- | --------------------------------------------------------------- | | `backupMethod` | `BackupMethods` | Backup method to use | | `backupConfigs` | `BackupConfigs` | Backup configuration | | `orgBackupShares` | `OrgBackupShares` | Organization backup shares for both curves | | `clientBackupCipherText` | `string` | Client backup cipher text (required if not using Portal backup) | **Returns** `Promise` containing: * `SECP256K1`: The SECP256K1 private key * `ED25519`: The ED25519 private key *** ## Transaction Methods ### request Makes an RPC request to a blockchain network. This is the primary method for interacting with blockchains. ```typescript theme={null} public async request(request: RequestArguments): Promise ``` **Parameters** | Name | Type | Description | | ------------------------------- | --------- | -------------------------------------------------------------------- | | `request.chainId` | `string` | Chain ID in CAIP-2 format (e.g., `'eip155:1'`) | | `request.method` | `string` | RPC method name (e.g., `'eth_sendTransaction'`) | | `request.params` | `any` | Method parameters | | `request.sponsorGas` | `boolean` | Optional. Whether to sponsor gas (Account Abstraction). | | `request.signatureApprovalMemo` | `string` | Optional. Memo shown to the user during the signature approval flow. | **Returns** `Promise` - Response from the RPC call **Example Usage** ```typescript theme={null} // Send Ethereum transaction const txHash = await portal.request({ chainId: 'eip155:1', method: 'eth_sendTransaction', params: [ { from: portal.address, to: '0x...', value: '0xDE0B6B3A7640000', // 1 ETH in wei }, ], signatureApprovalMemo: 'Send 1 ETH', // optional }); // Sign Solana transaction const signature = await portal.request({ chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', method: 'sol_signAndSendTransaction', params: [transaction], }); ``` ### sendAsset Helper method to send assets to another address. Supports EVM (`eip155:*`), Solana (`solana:*`), and TRON (`tron:*`) chains. ```typescript theme={null} public async sendAsset(chain: string, params: SendAssetParams): Promise ``` **Parameters** | Name | Type | Description | | ------------------------------ | --------- | -------------------------------------------------------------------- | | `chain` | `string` | Chain ID in CAIP-2 format (e.g., `'eip155:1'`, `'tron:mainnet'`) | | `params.to` | `string` | Recipient address | | `params.token` | `string` | Token contract address, or `'NATIVE'` for the chain's native token | | `params.amount` | `string` | Amount to send | | `params.sponsorGas` | `boolean` | Optional. Whether to sponsor gas (Account Abstraction, EVM only). | | `params.signatureApprovalMemo` | `string` | Optional. Memo shown to the user during the signature approval flow. | **Returns** `Promise` — transaction hash (EVM / Solana) or transaction ID (TRON) **Example Usage** ```typescript theme={null} // EVM const txHash = await portal.sendAsset('eip155:1', { to: '0x...', token: 'NATIVE', // Native ETH amount: '1000000000000000000', // 1 ETH in wei }); console.log('Transaction hash:', txHash); // TRON const txId = await portal.sendAsset('tron:mainnet', { to: 'TRecipientAddress', token: 'NATIVE', // TRX amount: '1', signatureApprovalMemo: 'Send TRX', // optional }); console.log('Transaction ID:', txId); ``` TRON transaction confirmation polling is not supported. `portal.waitForConfirmation` always returns `false` for `tron:*` chains. Verify the transaction status directly via TRON RPC using the transaction ID returned by `sendAsset`. ### sendSol Helper method to send SOL tokens. ```typescript theme={null} public async sendSol({ chainId, to, lamports }: { chainId: string to: string lamports: number }): Promise ``` **Parameters** | Name | Type | Description | | ---------- | -------- | --------------------------------------------------- | | `chainId` | `string` | Solana chain ID (must start with `'solana:'`) | | `to` | `string` | Recipient Solana address (44 characters) | | `lamports` | `number` | Amount in lamports (1 SOL = 1,000,000,000 lamports) | **Returns** `Promise` - Transaction signature **Example Usage** ```typescript theme={null} const signature = await portal.sendSol({ chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', to: 'DYw8...', lamports: 1000000000, // 1 SOL }); ``` ### sendEth Helper method to send ETH. ```typescript theme={null} public sendEth = async ({ chainId, to, value }: { chainId: string to: string value: string }): Promise ``` **Parameters** | Name | Type | Description | | --------- | -------- | -------------------------- | | `chainId` | `string` | EIP-155 chain ID | | `to` | `string` | Recipient address | | `value` | `string` | Amount in wei (hex string) | **Returns** `Promise` - Transaction hash **Example Usage** ```typescript theme={null} const txHash = await portal.sendEth({ chainId: 'eip155:1', to: '0x...', value: '0xDE0B6B3A7640000', // 1 ETH in wei }); console.log('Transaction hash:', txHash); ``` ### rawSign Signs data directly using the specified cryptographic curve. An optional third argument accepts **`signatureApprovalMemo`** which is shown to the user during the approval flow. ```typescript theme={null} public async rawSign( curve: PortalCurve, param: string, options?: RawSignOptions ): Promise ``` **Parameters** | Name | Type | Description | | --------- | ---------------- | ---------------------------------------------- | | `curve` | `PortalCurve` | Cryptographic curve (`ED25519` or `SECP256K1`) | | `param` | `string` | Data to sign | | `options` | `RawSignOptions` | Optional. `{ signatureApprovalMemo?: string }` | **Returns** `Promise` - Signature **Example Usage** ```typescript theme={null} import { PortalCurve } from '@portal-hq/web'; // Sign with SECP256K1 (Ethereum) const signature = await portal.rawSign(PortalCurve.SECP256K1, 'your-data-to-sign'); // With optional approval memo (shown to user during approval) const signatureWithMemo = await portal.rawSign( PortalCurve.SECP256K1, 'your-data-to-sign', { signatureApprovalMemo: 'Sign login challenge' } ); console.log('Signature:', signature); ``` *** ### sendBatchUserOp Builds, signs, and broadcasts a batch of token transfers as a single ERC-4337 UserOperation. Only available on Account Abstraction clients. Chain must be `eip155:`-prefixed. ```typescript theme={null} public async sendBatchUserOp( data: SendBatchUserOpRequest ): Promise ``` **`SendBatchUserOpRequest` fields:** | Field | Type | Required | Description | | ----------------------- | ------------------------------ | -------- | -------------------------------------------- | | `chain` | `string` | Yes | CAIP-2 chain ID (must start with `eip155:`) | | `transactions` | `SendBatchUserOpTransaction[]` | Yes | Ordered list of transfers to batch | | `signatureApprovalMemo` | `string` | No | Optional memo shown during the approval flow | **`SendBatchUserOpTransaction` fields:** | Field | Type | Description | | ------- | -------- | ------------------------------------------- | | `token` | `string` | Token symbol (e.g. `'USDC'`, `'NATIVE'`) | | `value` | `string` | Human-readable amount | | `to` | `string` | Recipient EVM address (`0x` + 40 hex chars) | **Returns** `Promise` — `{ data: { userOpHash: string }, metadata: { chainId: string } }` **Example Usage** ```typescript theme={null} import type { SendBatchUserOpRequest } from '@portal-hq/web' const result = await portal.sendBatchUserOp({ chain: 'eip155:10143', transactions: [ { token: 'USDC', value: '5.00', to: '0xAlice...' }, { token: 'USDC', value: '5.00', to: '0xBob...' }, ], signatureApprovalMemo: 'Batch transfer', }) console.log('UserOp hash:', result.data.userOpHash) ``` See [Batch User Operations](/resources/account-abstraction#batch-user-operations-web-sdk) for the full guide. *** ### sendBatchedAssets Builds, signs, and broadcasts a gas-subsidized batch that includes a reimbursement transfer — in a fee token you supply — to recover the paymaster gas cost. Requires an AA client. Chain must be `eip155:`-prefixed. ```typescript theme={null} public async sendBatchedAssets( data: SendBatchedAssetsRequest ): Promise ``` **`SendBatchedAssetsRequest` fields:** | Field | Type | Required | Description | | ----------------------- | ------------------------------ | -------- | -------------------------------------------------------------------- | | `chain` | `string` | Yes | CAIP-2 chain ID (must start with `eip155:`) | | `transactions` | `SendBatchUserOpTransaction[]` | Yes | The user's actual transfers | | `gasReimbursement` | `GasReimbursement` | Yes | Reimbursement config — fee token, recipient, and conversion callback | | `signatureApprovalMemo` | `string` | No | Optional memo shown during the approval flow | **`GasReimbursement` fields:** | Field | Type | Required | Description | | ----------------------- | --------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `feeToken` | `string` | Yes | Token symbol charged for reimbursement (e.g. `'USDC'`) | | `feeRecipient` | `string` | Yes | EVM address that receives the reimbursement | | `convertGasToFeeAmount` | `(gasCostWei: bigint) => string \| Promise` | Yes | Platform-supplied conversion — Portal provides the native gas cost in wei; you return the fee-token amount as a decimal string. Portal does not perform this conversion. | | `bufferBps` | `number` | No | Safety margin in basis points applied to the gas cost before conversion (e.g. `1000` = +10%). Defaults to `0`. | | `placeholderAmount` | `string` | No | Amount used for the fee call during the estimation pass. Defaults to `'0.01'`. Must be ≤ the wallet's balance. | **Returns** `Promise` See [Batch User Operations](/resources/account-abstraction#batch-user-operations-web-sdk) for the full guide including the two-pass build flow. *** ### buildBatchedUserOp Low-level: builds an ERC-4337 UserOperation from an ordered list of raw calls without signing or broadcasting it. Use this when you need direct control over the sign/broadcast steps. ```typescript theme={null} public async buildBatchedUserOp( data: BuildBatchedUserOpRequest ): Promise ``` **`BuildBatchedUserOpRequest` fields:** | Field | Type | Required | Description | | ------- | --------------------- | -------- | ------------------------------------------- | | `chain` | `string` | Yes | CAIP-2 chain ID (must start with `eip155:`) | | `calls` | `UserOperationCall[]` | Yes | Ordered list of raw calls | **`UserOperationCall` fields:** | Field | Type | Description | | ------- | -------- | ------------------------------------------------------------------------------ | | `to` | `string` | Target address | | `value` | `string` | (optional) Native token amount in wei (decimal string). Omit for ERC-20 calls. | | `data` | `string` | (optional) Calldata hex string. Defaults to `'0x'` for native transfers. | **`BuildBatchedUserOpResponse.metadata` fields** (all optional — backends that predate this change omit them): | Field | Description | | --------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `totalGas` | Sum of all ERC-4337 gas limit fields (decimal string). The prefund multiplier is not applied. | | `maxFeePerGas` | Price per gas unit in wei (decimal string). `'0'` on chains with no on-chain fee. | | `estimatedGasCostWei` | Build-time upper bound gas cost in wei (`totalGas × maxFeePerGas`). Use this as the authoritative value to charge against. | *** ### broadcastBatchedUserOp Low-level: broadcasts a signed UserOperation to the bundler. ```typescript theme={null} public async broadcastBatchedUserOp( data: BroadcastBatchedUserOpRequest ): Promise ``` **`BroadcastBatchedUserOpRequest` fields:** | Field | Type | Required | Description | | --------------- | -------- | -------- | -------------------------------------------------------------- | | `chain` | `string` | Yes | CAIP-2 chain ID | | `userOperation` | `string` | Yes | Serialized UserOperation JSON string from `buildBatchedUserOp` | | `signature` | `string` | Yes | Signature over `userOpHash` (without `0x` prefix) | When signing `userOpHash` manually with `rawSign`, strip the `0x` prefix before passing it: `userOpHash.replace(/^0x/, '')`. *** ## Delegations
`portal.delegations` exposes approve, revoke, status, transfer, and high-level sign-and-submit helpers for token delegations on supported EVM and Solana chains. See [Manage Token Delegations](/sdks/web/guide/delegations) for walkthroughs. The interfaces below match the types exported from `@portal-hq/web` (for example `import type { ApproveDelegationRequest } from '@portal-hq/web'`). ### `portal.delegations` methods | Method | Signature | | ------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `approve` | `approve(params: ApproveDelegationRequest): Promise` | | `revoke` | `revoke(params: RevokeDelegationRequest): Promise` | | `getStatus` | `getStatus(params: GetDelegationStatusRequest): Promise` | | `transferFrom` | `transferFrom(params: TransferFromRequest): Promise` | | `approveAndSubmit` | `approveAndSubmit(params: ApproveDelegationRequest, options?: DelegationSubmitOptions): Promise<{ hashes: string[] }>` | | `revokeAndSubmit` | `revokeAndSubmit(params: RevokeDelegationRequest, options?: DelegationSubmitOptions): Promise<{ hashes: string[] }>` | | `transferAndSubmit` | `transferAndSubmit(params: TransferFromRequest, options?: DelegationSubmitOptions): Promise<{ hashes: string[] }>` | ### Request and options types #### ApproveDelegationRequest ```typescript theme={null} interface ApproveDelegationRequest { chain: string token: string delegateAddress: string amount: string } ``` #### RevokeDelegationRequest ```typescript theme={null} interface RevokeDelegationRequest { chain: string token: string delegateAddress: string } ``` #### GetDelegationStatusRequest ```typescript theme={null} interface GetDelegationStatusRequest { chain: string token: string delegateAddress: string } ``` #### TransferFromRequest ```typescript theme={null} interface TransferFromRequest { chain: string token: string fromAddress: string toAddress: string amount: string } ``` #### DelegationSubmitOptions Optional second argument for `approveAndSubmit`, `revokeAndSubmit`, and `transferAndSubmit`. ```typescript theme={null} interface DelegationSubmitOptions { signAndSendTransaction?: ( transaction: unknown, chainId: string ) => Promise onProgress?: (event: DelegationSubmitProgress) => void } ``` #### DelegationSubmitProgress ```typescript theme={null} interface DelegationSubmitProgress { step: 'signing' | 'submitted' index: number total: number hash?: string } ``` *** ## Yield Types Types used by the high-level `deposit` and `withdraw` methods on `portal.yield.yieldXyz`. All types are exported from `@portal-hq/web`. #### YieldDepositParams Union type — provide either `yieldId` or `chain` + `token`. A non-empty `yieldId` takes precedence. ```typescript theme={null} type YieldDepositParams = | { yieldId: string; amount: string; address?: string; arguments?: YieldXyzEnterArguments } | { chain: string; token: string; amount: string; address?: string; arguments?: YieldXyzEnterArguments } ``` #### YieldWithdrawParams Same union as `YieldDepositParams`. ```typescript theme={null} type YieldWithdrawParams = YieldDepositParams ``` #### YieldSubmitOptions ```typescript theme={null} interface YieldSubmitOptions { onProgress?: (event: YieldSubmitProgress) => void signAndSendTransaction?: (transaction: unknown, network: string) => Promise waitForConfirmation?: (txHash: string, network: string) => Promise evmRequestFn?: (method: string, params: unknown[], network: string) => Promise evmPollerOptions?: { pollIntervalMs?: number; timeoutMs?: number } } ``` #### YieldSubmitProgress ```typescript theme={null} interface YieldSubmitProgress { step: 'signing' | 'submitted' | 'confirming' | 'confirmed' index: number total: number hash?: string } ``` #### YieldDepositResult ```typescript theme={null} interface YieldDepositResult { hashes: string[] yieldId: string chain?: string token?: string yieldOpportunityDetails: { yieldId: string intent?: string type?: string executionPattern?: string status?: string amount?: string | null amountUsd?: string | null } } ``` #### YieldWithdrawResult Same shape as `YieldDepositResult`. ```typescript theme={null} interface YieldWithdrawResult { hashes: string[] yieldId: string chain?: string token?: string yieldOpportunityDetails: { yieldId: string intent?: string type?: string executionPattern?: string status?: string amount?: string | null amountUsd?: string | null } } ``` #### YieldXyzValidator ```typescript theme={null} interface YieldXyzValidator { address: string name?: string commission?: string apy?: string [key: string]: unknown } ``` *** ## API Methods ### getClient Gets information about the client and their wallets. ```typescript theme={null} public async getClient(): Promise ``` **Returns** `Promise` with client information including: * `id`: Client ID * `address`: Primary address * `wallets`: Array of wallet information * `metadata`: Namespace metadata with addresses **Example Usage** ```typescript theme={null} const client = await portal.getClient(); console.log('Client ID:', client.id); console.log('Wallets:', client.wallets); ``` ### getAssets Gets the assets (tokens) held by the wallet. ```typescript theme={null} public async getAssets(chainId: string, includeNfts?: boolean): Promise ``` **Parameters** | Name | Type | Description | | ------------- | --------- | ------------------------------------------ | | `chainId` | `string` | Chain ID to query | | `includeNfts` | `boolean` | Whether to include NFTs (default: `false`) | **Returns** `Promise` with asset information **Example Usage** ```typescript theme={null} const assets = await portal.getAssets('eip155:1', false); console.log('Assets:', assets); ``` ### getNFTAssets Gets NFT assets held by the wallet. ```typescript theme={null} public async getNFTAssets(chainId: string): Promise ``` **Parameters** | Name | Type | Description | | --------- | -------- | ----------------- | | `chainId` | `string` | Chain ID to query | **Returns** `Promise` - Array of NFT assets **Example Usage** ```typescript theme={null} const nfts = await portal.getNFTAssets('eip155:1'); console.log('NFT assets:', nfts); ``` ### getTransactionHistory Returns the transaction history for a wallet across supported chains. Replaces the legacy `getTransactions` method with pagination and extended support for modern transaction types (including ERC-4337 UserOperations on EVM chains). EVM responses use the normalized format documented below, while Solana currently returns its legacy response shape and will be unified in a future update. ```typescript theme={null} public async getTransactionHistory( params: GetTransactionHistoryParams ): Promise ``` **Parameters** | Name | Type | Description | | ----------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `params.chainId` | `string` | Chain ID in CAIP-2 format (e.g., `'eip155:1'`, `'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'`) | | `params.limit` | `number` | Maximum number of transactions to return (default: 50; max: 1000 for EVM, 15 for Solana) | | `params.offset` | `number` | Number of transactions to skip for pagination (default: 0) | | `params.order` | `'asc' \| 'desc'` | Sort order by block number (default: `'desc'`, EVM only) | | `params.address` | `string` | Optional address override (EVM only) | | `params.userOperations` | `'include' \| 'only' \| 'exclude'` | Optional. Filter ERC-4337 UserOperations on **EVM chains (`eip155:*`)** only. If set, the SDK always sends that value unchanged. | **Default behavior (`userOperations`)** * **EVM (`eip155:*`)** — If the authenticated client has Account Abstraction enabled (`isAccountAbstracted`), the SDK automatically sends `userOperations=only`. If the client is an EOA, the SDK does not send the parameter. * **Non-EVM chains** (e.g. `solana:*`, Bitcoin, Tron, Stellar) — The SDK never injects `userOperations`; the filter does not apply to those namespaces. If `userOperations` is provided, it always overrides this behavior. **Returns** `Promise` **For Solana chains (`solana:*`)**: ```typescript theme={null} { data: { transactions: SolanaTransactionDetails[] }, metadata: { address: string chainId: string clientId: string limit: number offset: number count: number } } ``` **For EVM, Bitcoin, Tron, Stellar chains**: ```typescript theme={null} { data: { transactions: TransactionHistoryItem[] }, metadata: { address: string chainId: string clientId: string limit: number offset: number count: number } } ``` **TransactionHistoryItem** is a discriminated union: * **RegularTransaction**: `type: 'transaction'` with optional token metadata (`asset`, `tokenAddress`, `tokenDecimals`) * **UserOperationTransaction**: `type: 'userOperation'` with UserOp fields (`userOpHash`, `entryPoint`, `actualGasCost`, `actualGasUsed`) **Example Usage** ```typescript theme={null} // Get EVM transactions including UserOperations const evmTxs = await portal.getTransactionHistory({ chainId: 'eip155:1', limit: 20, order: 'desc', userOperations: 'include', // Include both regular txs and UserOps }); // Type narrowing based on transaction type evmTxs.data.transactions.forEach(tx => { if (tx.type === 'userOperation') { console.log('UserOp hash:', tx.userOpHash); console.log('Entry point:', tx.entryPoint); // tx.asset not available (compile error) } else { console.log('Token:', tx.asset); console.log('Token address:', tx.tokenAddress); // tx.userOpHash not available (compile error) } }); // EVM + Account Abstraction: omit `userOperations` // If wallet is AA-enabled, SDK injects `userOperations='only'` // Otherwise (EOA), the parameter is not sent const aaEvmHistory = await portal.getTransactionHistory({ chainId: 'eip155:42161', limit: 20, }); // Get Solana transactions const solanaTxs = await portal.getTransactionHistory({ chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', limit: 15, }); // Solana transactions have a different structure solanaTxs.data.transactions.forEach(tx => { console.log('Signature:', tx.signature); console.log('Block time:', tx.blockTime); console.log('Status:', tx.status); }); // Get only UserOperations (EVM only) const userOps = await portal.getTransactionHistory({ chainId: 'eip155:137', userOperations: 'only', limit: 10, }); ``` ### getTransactions **Deprecated**: Use `getTransactionHistory()` instead, which provides improved type safety with discriminated unions for regular transactions and UserOperations, and proper polymorphic response types for Solana vs unified formats. Gets transaction history for the wallet. ```typescript theme={null} public async getTransactions( chainId: string, limit?: number, offset?: number, order?: GetTransactionsOrder ): Promise ``` **Parameters** | Name | Type | Description | | --------- | ---------------------- | ---------------------------------------- | | `chainId` | `string` | Chain ID to query | | `limit` | `number` | Maximum number of transactions to return | | `offset` | `number` | Number of transactions to skip | | `order` | `GetTransactionsOrder` | Sort order: `'asc'` or `'desc'` | **Returns** `Promise` - Array of transactions **Example Usage** ```typescript theme={null} import { GetTransactionsOrder } from '@portal-hq/web'; // ⚠️ DEPRECATED: Use getTransactionHistory() instead const txs = await portal.getTransactions('eip155:1', 10, 0, GetTransactionsOrder.DESC); // ✅ RECOMMENDED: Use the new method with improved type safety const walletTxs = await portal.getTransactionHistory({ chainId: 'eip155:1', limit: 10, offset: 0, order: 'desc', }); ``` ### evaluateTransaction Evaluates a transaction before execution to check if the transaction can be executed, and perform security validations. ```typescript theme={null} public async evaluateTransaction( chainId: string, transaction: EvaluateTransactionParam, operationType?: EvaluateTransactionOperationType ): Promise ``` **Parameters** | Name | Type | Description | | --------------- | ---------------------------------- | ------------------------------------- | | `chainId` | `string` | Chain ID | | `transaction` | `EvaluateTransactionParam` | Transaction to evaluate | | `operationType` | `EvaluateTransactionOperationType` | Type of evaluation (default: `'all'`) | **Returns** `Promise` with security analysis **Example Usage** ```typescript theme={null} const evaluation = await portal.evaluateTransaction('eip155:1', { from: portal.address, to: '0x...', value: '0x1', }); console.log('Security warnings:', evaluation.warnings); ``` ### buildTransaction Builds a transaction for sending tokens. ```typescript theme={null} public async buildTransaction( chainId: string, to: string, token: string, amount: string ): Promise ``` **Parameters** | Name | Type | Description | | --------- | -------- | ---------------------- | | `chainId` | `string` | Chain ID | | `to` | `string` | Recipient address | | `token` | `string` | Token contract address | | `amount` | `string` | Amount to send | **Returns** `Promise` with the built transaction object **Example Usage** ```typescript theme={null} const builtTx = await portal.buildTransaction( 'eip155:1', '0x...', '0x0000000000000000000000000000000000000000', // Native ETH '1000000000000000000' // 1 ETH in wei ); // Use the built transaction const txHash = await portal.request({ chainId: 'eip155:1', method: 'eth_sendTransaction', params: [builtTx.transaction], }); ``` ### receiveTestnetAsset Requests testnet assets from a faucet. ```typescript theme={null} public async receiveTestnetAsset(chainId: string, params: FundParams): Promise ``` **Parameters** | Name | Type | Description | | --------------- | -------- | ----------------- | | `chainId` | `string` | Testnet chain ID | | `params.amount` | `string` | Amount to request | | `params.token` | `string` | Token identifier | **Returns** `Promise` with funding details **Example Usage** ```typescript theme={null} const fundResponse = await portal.receiveTestnetAsset('eip155:11155111', { amount: '1', token: 'ETH', }); console.log('Transaction hash:', fundResponse.data?.txHash); console.log('Explorer URL:', fundResponse.data?.explorerUrl); ``` *** ## Swap Methods ### getQuote Gets a quote for an in-chain token swap. Deprecated: Use the `portal.trading.zerox.getQuote` method instead. ```typescript theme={null} public async getQuote( apiKey: string, args: QuoteArgs, chainId: string ): Promise ``` ### getSources Gets available swap sources for in-chain swaps. Deprecated: Use the `portal.trading.zerox.getSources` method instead. ```typescript theme={null} public async getSources(apiKey: string, chainId: string): Promise> ``` *** ## Utility Methods ### updateChain Updates the current chain ID for the provider. ```typescript theme={null} public updateChain(newChainId: string): void ``` **Parameters** | Name | Type | Description | | ------------ | -------- | ------------------- | | `newChainId` | `string` | New chain ID to set | **Example Usage** ```typescript theme={null} portal.updateChain('eip155:137'); // Switch to Polygon ``` ### getRpcUrl Gets the configured RPC URL for a chain. ```typescript theme={null} public getRpcUrl(chainId?: string): string ``` **Parameters** | Name | Type | Description | | --------- | -------- | ------------------- | | `chainId` | `string` | Chain ID to look up | **Returns** `string` - The RPC URL **Throws** Error if chain ID is not configured ### storedClientBackupShare Notifies Portal that a backup share has been stored. ```typescript theme={null} public async storedClientBackupShare(success: boolean, backupMethod: BackupMethods): Promise ``` **Parameters** | Name | Type | Description | | -------------- | --------------- | --------------------------------- | | `success` | `boolean` | Whether the backup was successful | | `backupMethod` | `BackupMethods` | The backup method that was used | **Returns** `Promise` **Example Usage** ```typescript theme={null} import { BackupMethods } from '@portal-hq/web'; // After successfully storing a backup await portal.storedClientBackupShare(true, BackupMethods.gdrive); ``` *** ## Integration Classes ### portal.yield (Yield) The `Yield` class provides access to [Yield.xyz](/integrations/Yield/yield-xyz) integration for yield farming opportunities. **Properties** | Property | Type | Description | | ---------- | ---------- | ------------------------------ | | `yieldXyz` | `YieldXyz` | Yield.xyz integration instance | #### getValidators Fetches validator addresses for a specific `yieldId`. Delegates to `portal.yield.yieldXyz.getValidators`. Throws if the response does not contain a valid validators array. ```typescript theme={null} public getValidators(yieldId: string): Promise ``` **Parameters** | Name | Type | Description | | --------- | -------- | ---------------------------------------------------------------------- | | `yieldId` | `string` | Yield opportunity identifier (e.g. `monad-testnet-mon-native-staking`) | **Returns** `Promise` — Array of validator objects. Each entry includes at minimum `address: string` plus optional `name`, `commission`, `apy`, and any additional provider-specific fields. **Example Usage** ```typescript theme={null} const validators = await portal.yield.getValidators('monad-testnet-mon-native-staking') console.log('Validators:', validators) ``` ### portal.yield.yieldXyz (YieldXyz) Access yield farming features through the Yield.xyz integration. Includes low-level methods (`discover`, `enter`, `exit`, `manage`, `track`, `getTransaction`, `getBalances`, `getHistoricalActions`) and high-level helpers (`deposit`, `withdraw`). See the [Yield.xyz guide](/sdks/web/guide/yield-xyz) for walkthroughs. #### deposit High-level deposit: resolves the yield, builds the enter action, signs and sends each transaction in order, waits for confirmation between steps when configured, and reports hashes to Yield.xyz — all in one call. See the [Yield.xyz guide](/sdks/web/guide/yield-xyz#high-level-methods) for parameter tables and examples. ```typescript theme={null} public async deposit( params: YieldDepositParams, options?: YieldSubmitOptions, ): Promise ``` **Parameters** | Name | Type | Required | Description | | --------- | -------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `params` | `YieldDepositParams` | Yes | Either `{ yieldId, amount }` or `{ chain, token, amount }`. Non-empty `yieldId` takes precedence. `chain` must be a full CAIP-2 id (e.g. `eip155:11155111`). Optional `address` and `arguments`. | | `options` | `YieldSubmitOptions` | No | `onProgress`, per-call `signAndSendTransaction`, `waitForConfirmation`, `evmRequestFn`, `evmPollerOptions`. | **Returns** `Promise` with fields: | Field | Type | Description | | ------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------ | | `hashes` | `string[]` | Submitted tx hashes, in order. | | `yieldId` | `string` | Resolved yield id. | | `chain` | `string?` | Echoed when you passed `chain` + `token`. | | `token` | `string?` | Echoed when you passed `chain` + `token`. | | `yieldOpportunityDetails` | `object` | Action metadata from Yield.xyz (`yieldId`, `intent`, `type`, `executionPattern`, `status`, `amount`, `amountUsd`). | #### withdraw High-level withdraw: same dual-input modes, `yieldId` resolution, signer fallback, and confirmation behavior as `deposit`, but calls the Yield.xyz exit action. See the [Yield.xyz guide](/sdks/web/guide/yield-xyz#high-level-methods) for parameter tables and examples. ```typescript theme={null} public async withdraw( params: YieldWithdrawParams, options?: YieldSubmitOptions, ): Promise ``` **Parameters** | Name | Type | Required | Description | | --------- | --------------------- | -------- | ----------------------------------- | | `params` | `YieldWithdrawParams` | Yes | Same union as `YieldDepositParams`. | | `options` | `YieldSubmitOptions` | No | Same options as `deposit`. | **Returns** `Promise` — Same shape as `YieldDepositResult`. #### discover Discovers available yield opportunities. ```typescript theme={null} public async discover(data: YieldXyzGetYieldsRequest): Promise ``` **Parameters** | Name | Type | Description | | ------ | -------------------------- | ------------------------------ | | `data` | `YieldXyzGetYieldsRequest` | Parameters for yield discovery | **Returns** `Promise` - Available yield opportunities **Example Usage** ```typescript theme={null} const yields = await portal.yield.yieldXyz.discover({ // Discovery parameters }); console.log('Available yields:', yields); ``` #### getBalances Retrieves yield balances for specified addresses and networks. ```typescript theme={null} public async getBalances(data: YieldXyzGetBalancesRequest): Promise ``` **Parameters** | Name | Type | Description | | ------ | ---------------------------- | --------------------------------------------------- | | `data` | `YieldXyzGetBalancesRequest` | Request parameters including addresses and networks | **Returns** `Promise` - Balance information #### getHistoricalActions Retrieves historical yield actions with optional filtering. ```typescript theme={null} public async getHistoricalActions( data: YieldXyzGetHistoricalActionsRequest ): Promise ``` **Returns** `Promise` - Historical actions #### enter Enters a yield opportunity. ```typescript theme={null} public async enter(data: YieldXyzEnterRequest): Promise ``` **Parameters** | Name | Type | Description | | ------ | ---------------------- | ------------------------------------------- | | `data` | `YieldXyzEnterRequest` | Parameters for entering a yield opportunity | **Returns** `Promise` - Action details **Example Usage** ```typescript theme={null} const result = await portal.yield.yieldXyz.enter({ // Enter parameters }); ``` #### exit Exits a yield opportunity. ```typescript theme={null} public async exit(data: YieldXyzExitRequest): Promise ``` **Parameters** | Name | Type | Description | | ------ | --------------------- | ------------------------------------------ | | `data` | `YieldXyzExitRequest` | Parameters for exiting a yield opportunity | **Returns** `Promise` - Action details #### manage Manages a yield opportunity with specified parameters. ```typescript theme={null} public async manage(data: YieldXyzManageYieldRequest): Promise ``` **Parameters** | Name | Type | Description | | ------ | ---------------------------- | ------------------------------------------- | | `data` | `YieldXyzManageYieldRequest` | Parameters for managing a yield opportunity | **Returns** `Promise` - Action details #### track Tracks a transaction by submitting its hash. ```typescript theme={null} public async track(data: YieldXyzTrackTransactionRequest): Promise ``` **Parameters** | Name | Type | Description | | -------------------- | -------- | ------------------------------------- | | `data.transactionId` | `string` | The ID of the transaction to track | | `data.txHash` | `string` | The hash of the transaction to submit | **Returns** `Promise` - Tracking confirmation #### getTransaction Retrieves a single yield action transaction by its ID. ```typescript theme={null} public async getTransaction(transactionId: string): Promise ``` **Parameters** | Name | Type | Description | | --------------- | -------- | ------------------------------ | | `transactionId` | `string` | The transaction ID to retrieve | **Returns** `Promise` - Transaction details *** ### portal.trading (Trading) The `Trading` class provides access to: * [Li.Fi](/integrations/Trading/lifi) integration for cross-chain swaps and bridges * [0x](./guide/perform-swaps) integration for in-chain swaps **Properties** | Property | Type | Description | | -------- | ------- | -------------------------- | | `lifi` | `LiFi` | Li.Fi integration instance | | `zeroX` | `ZeroX` | 0x integration instance | ### portal.trading.lifi (LiFi) Access cross-chain swap and bridge features through the Li.Fi integration. Includes `tradeAsset` for end-to-end trades and lower-level methods for manual flows. See the [Li.Fi guide](/sdks/web/guide/lifi) for walkthroughs. #### tradeAsset Runs the end-to-end Li.Fi flow in one call: discover routes, select a route, build each step, sign and broadcast, wait for confirmation, poll Li.Fi status for cross-chain steps, and return hashes. See the [Li.Fi guide](/sdks/web/guide/lifi#tradeasset) for parameter tables, progress lifecycle, and examples. ```typescript theme={null} public async tradeAsset( params: LifiTradeAssetParams, options?: LifiTradeAssetOptions, ): Promise ``` **Parameters** | Name | Type | Required | Description | | --------- | ----------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `params` | `LifiTradeAssetParams` | Yes | `fromChain`, `toChain`, `fromToken`, `toToken`, `amount`, `fromAddress` (required); `toAddress`, `routeOptions`, `routeIndex`, `onProgress`, `statusPoll` (optional). | | `options` | `LifiTradeAssetOptions` | No | Per-call `signAndSendTransaction`, `waitForConfirmation`, `evmRequestFn`, `evmPollerOptions`. | **Returns** `Promise` with fields: | Field | Type | Description | | -------- | ------------ | ------------------------------------- | | `hashes` | `string[]` | Transaction hashes per executed step. | | `steps` | `LifiStep[]` | Step objects from the API. | | `route` | `LifiRoute` | The executed route. | #### pollStatus Built-in Li.Fi status polling with retries and exponential backoff. Use when you already have a tx hash and want the same polling behavior as inside `tradeAsset`. See the [Li.Fi guide](/sdks/web/guide/lifi#pollstatus) for option defaults and examples. ```typescript theme={null} public async pollStatus( request: Pick, options?: LifiPollStatusOptions & { onUpdate?: (raw: LifiStatusRawResponse) => boolean | void }, ): Promise ``` **Parameters** | Name | Type | Required | Description | | ------------------------------ | ------------------------------------------------- | -------- | --------------------------------------------------------------- | | `request.txHash` | `string` | Yes | Transaction hash to poll. | | `request.fromChain` | `string` | Yes | Source chain. | | `request.toChain` | `string` | No | Destination chain. | | `request.bridge` | `string` | No | Bridge tool identifier. | | `options.everyMs` | `number` | No | Interval between requests (default `10000`). | | `options.initialDelayMs` | `number` | No | Delay before the first request (default `10000`). | | `options.timeoutMs` | `number` | No | Max total poll time (default `600000`). | | `options.maxConsecutiveErrors` | `number` | No | Abort after this many consecutive errors (default `10`). | | `options.backoff` | `{ factor: number; maxIntervalMs: number }` | No | Backoff config (default `factor: 1.5`, `maxIntervalMs: 15000`). | | `options.onUpdate` | `(raw: LifiStatusRawResponse) => boolean \| void` | No | Return `false` to stop early. | **Returns** `Promise` — Terminal status response. #### getRoutes Retrieves available routes for cross-chain swaps and bridges. ```typescript theme={null} public async getRoutes(data: LifiRoutesRequest): Promise ``` **Parameters** | Name | Type | Description | | ------ | ------------------- | ------------------------------ | | `data` | `LifiRoutesRequest` | Parameters for route discovery | **Returns** `Promise` - Available routes **Example Usage** ```typescript theme={null} const routes = await portal.trading.lifi.getRoutes({ fromChainId: 1, toChainId: 137, fromTokenAddress: '0x...', toTokenAddress: '0x...', fromAmount: '1000000000000000000', }); console.log('Available routes:', routes); ``` #### getQuote Retrieves a quote for a swap or bridge operation. ```typescript theme={null} public async getQuote(data: LifiQuoteRequest): Promise ``` **Parameters** | Name | Type | Description | | ------ | ------------------ | -------------------------------- | | `data` | `LifiQuoteRequest` | Parameters for the quote request | **Returns** `Promise` - Quote details including fees and estimated time **Example Usage** ```typescript theme={null} const quote = await portal.trading.lifi.getQuote({ fromChain: '1', toChain: '137', fromToken: '0x...', toToken: '0x...', fromAmount: '1000000000000000000', fromAddress: portal.address, }); ``` #### getStatus Retrieves the status of a cross-chain transaction. ```typescript theme={null} public async getStatus(data: LifiStatusRequest): Promise ``` **Parameters** | Name | Type | Description | | ------ | ------------------- | ---------------------------------------------------- | | `data` | `LifiStatusRequest` | Status request parameters including transaction hash | **Returns** `Promise` - Transaction status **Example Usage** ```typescript theme={null} const status = await portal.trading.lifi.getStatus({ txHash: '0x...', bridge: 'hop', }); console.log('Transaction status:', status.status); ``` #### getRouteStep Retrieves an unsigned transaction for a specific route step. ```typescript theme={null} public async getRouteStep(data: LifiStepTransactionRequest): Promise ``` **Parameters** | Name | Type | Description | | ------ | ---------------------------- | ------------------------------------------ | | `data` | `LifiStepTransactionRequest` | Step transaction request with step details | **Returns** `Promise` - Unsigned transaction ready to be signed **Example Usage** ```typescript theme={null} const step = await portal.trading.lifi.getRouteStep({ route: selectedRoute, stepIndex: 0, }); // Sign and send the transaction const txHash = await portal.request({ chainId: 'eip155:1', method: 'eth_sendTransaction', params: [step.transactionRequest], }); ``` ### portal.trading.zeroX (0x) Access in-chain swap features through the 0x integration. See the [0x guide](./guide/zero-x) for full walkthroughs. #### tradeAsset Fetches a 0x quote, signs and broadcasts the transaction, waits for on-chain confirmation, and returns hashes. See the [0x guide](./guide/zero-x#tradeasset) for parameter tables and examples. ```typescript theme={null} public async tradeAsset( params: ZeroXTradeAssetParams, options?: ZeroXTradeAssetOptions, ): Promise ``` #### getPrice Retrieves an indicative price for a token swap without generating executable transaction data. ```typescript theme={null} public async getPrice( request: ZeroExPriceRequest, options?: { zeroXApiKey?: string }, ): Promise ``` #### getQuote Gets a swap quote with executable transaction data. ```typescript theme={null} public async getQuote( request: ZeroExQuoteRequest, options?: { zeroXApiKey?: string }, ): Promise ``` #### getSources Gets available liquidity sources for a chain. ```typescript theme={null} public async getSources( chainId: string, options?: { zeroXApiKey?: string }, ): Promise ``` *** ### portal.ramps (Ramps) The `Ramps` class groups fiat on/off-ramp integrations. **Properties** | Property | Type | Description | | -------- | ------ | ------------------------------------------------------------------ | | `noah` | `Noah` | [Noah](/integrations/On-Off-Ramp/noah) ramp API | | `meld` | `Meld` | [Meld](/integrations/On-Off-Ramp/meld) buy, sell, and transfer API | See the [Noah Web SDK guide](./guide/noah) and the [Meld Web SDK guide](./guide/meld) for end-to-end flows and prerequisites. ### portal.ramps.noah (Noah) Noah methods forward to the embedded Portal iframe, which calls `https://api.portalhq.io/api/v3/clients/me/integrations/noah/...` with the authenticated client session. Responses follow the `{ data, metadata? }` envelope used across Client API integrations. #### initiateKyc ```typescript theme={null} public async initiateKyc(data: NoahInitiateKycRequest): Promise ``` | Parameter | Type | Description | | --------- | ------------------------ | ------------------------------------------------------------------------------- | | `data` | `NoahInitiateKycRequest` | `returnUrl` (HTTPS), optional `fiatOptions`, `customerType`, `metadata`, `form` | **Returns** — `Promise` with `data.hostedUrl` for hosted onboarding. #### initiatePayin ```typescript theme={null} public async initiatePayin(data: NoahInitiatePayinRequest): Promise ``` | Parameter | Type | Description | | --------- | -------------------------- | ------------------------------------------------------------------------ | | `data` | `NoahInitiatePayinRequest` | `fiatCurrency`, `cryptoCurrency`, CAIP-2 `network`, `destinationAddress` | **Returns** — `Promise` with `data.payinId` and `data.bankDetails`. #### simulatePayin ```typescript theme={null} public async simulatePayin(data: NoahSimulatePayinRequest): Promise ``` | Parameter | Type | Description | | --------- | -------------------------- | ----------------------------------------------- | | `data` | `NoahSimulatePayinRequest` | `paymentMethodId`, `fiatAmount`, `fiatCurrency` | **Returns** — `Promise` (sandbox simulation payload). #### getPayoutCountries ```typescript theme={null} public async getPayoutCountries(): Promise ``` **Returns** — `Promise` with `data.countries`. #### getPayoutChannels ```typescript theme={null} public async getPayoutChannels(data: NoahGetPayoutChannelsRequest): Promise ``` | Parameter | Type | Description | | --------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | | `data` | `NoahGetPayoutChannelsRequest` | `cryptoCurrency` (required); optional `country`, `fiatCurrency`, `fiatAmount`, `paymentMethodId`, `pageSize`, `pageToken` | **Returns** — `Promise` (`data` shape is provider-specific). #### getPayoutChannelForm ```typescript theme={null} public async getPayoutChannelForm(channelId: string): Promise ``` | Parameter | Type | Description | | ----------- | -------- | ------------------------------------------ | | `channelId` | `string` | Payout channel id from `getPayoutChannels` | **Returns** — `Promise` (dynamic form schema). #### getPayoutQuote ```typescript theme={null} public async getPayoutQuote(data: NoahGetPayoutQuoteRequest): Promise ``` | Parameter | Type | Description | | --------- | --------------------------- | ----------------------------------------------------------------------------------------------- | | `data` | `NoahGetPayoutQuoteRequest` | `channelId`, `cryptoCurrency`, `fiatAmount`, optional `form`, `fiatCurrency`, `paymentMethodId` | **Returns** — `Promise` including `payoutId`, `formSessionId`, `cryptoAmountEstimate`, `totalFee`. #### initiatePayout ```typescript theme={null} public async initiatePayout(data: NoahInitiatePayoutRequest): Promise ``` | Parameter | Type | Description | | --------- | --------------------------- | ---------------------------------------------------------------------------------------- | | `data` | `NoahInitiatePayoutRequest` | `payoutId`, `sourceAddress`, ISO `expiry`, `nonce`, CAIP-2 `network`, optional `trigger` | **Returns** — `Promise` with `destinationAddress` and `conditions` for deposit legs when applicable. #### getPaymentMethods ```typescript theme={null} public async getPaymentMethods(data?: NoahGetPaymentMethodsRequest): Promise ``` **Returns** — `Promise` with `data.paymentMethods` and optional `pageToken`. *** ### portal.ramps.meld (Meld) Meld methods forward to the embedded Portal iframe, which calls `https://api.portalhq.io/api/v3/clients/me/integrations/meld/...` with the authenticated client session. Responses follow the `{ data, metadata? }` envelope used across Client API integrations. See the [Meld Web SDK guide](./guide/meld) for end-to-end flows with full examples. #### createCustomer ```typescript theme={null} public async createCustomer( data: MeldCreateCustomerRequest ): Promise ``` | Parameter | Type | Description | | --------- | --------------------------- | ----------------------------------------------------------------------------------------- | | `data` | `MeldCreateCustomerRequest` | Optional `name`, `email`, `phone`, `dateOfBirth`, `type` (`"INDIVIDUAL"` \| `"BUSINESS"`) | **Returns** — `Promise` with `data: MeldCustomer` (`id`, `externalId`, `accountId`, `name`, `email`, `type`, `status`). #### searchCustomer ```typescript theme={null} public async searchCustomer(): Promise ``` **Returns** — `Promise` with `data: { customers: MeldCustomer[]; count: number; remaining: number }`. #### getRetailQuote ```typescript theme={null} public async getRetailQuote( data: MeldGetRetailQuoteRequest ): Promise ``` | Parameter | Type | Description | | --------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `data` | `MeldGetRetailQuoteRequest` | Required: `countryCode`, `sourceCurrencyCode`, `destinationCurrencyCode`, `sourceAmount` (number). Optional: `walletAddress`, `customerId`, `paymentMethodType`, `serviceProviders`, `subdivision` | **Returns** — `Promise` with `data: { quotes: MeldQuote[]; message?: string; error?: string; timestamp?: string }`. Each `MeldQuote` includes required fields: `serviceProvider`, `transactionType`, `sourceAmount`, `sourceCurrencyCode`, `destinationAmount`, `destinationCurrencyCode`, `exchangeRate`, `transactionFee`, `totalFee`, `paymentMethodType`; and optional nullable fields: `sourceAmountWithoutFees`, `destinationAmountWithoutFees`, `networkFee`, `partnerFee`, `fiatAmountWithoutFees`, `countryCode`, `customerScore`, `institutionName`, `isNativeAvailable`, `rampIntelligence`. #### createRetailWidget ```typescript theme={null} public async createRetailWidget( data: MeldCreateRetailWidgetRequest ): Promise ``` | Parameter | Type | Description | | --------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `data` | `MeldCreateRetailWidgetRequest` | Required: `sessionType` (`"BUY"` \| `"SELL"` \| `"TRANSFER"`), `sessionData` (`MeldSessionData`). Optional: `externalSessionId`, `customerId`, `bypassKyc` | **Returns** — `Promise` with `data: { id, token, customerId, externalCustomerId, externalSessionId, widgetUrl }`. #### searchRetailTransactions ```typescript theme={null} public async searchRetailTransactions( data?: MeldSearchRetailTransactionsParams ): Promise ``` | Parameter | Type | Description | | --------- | ------------------------------------ | -------------------------------------------------- | | `data` | `MeldSearchRetailTransactionsParams` | Optional `status`, `limit`, `offset` (all strings) | **Returns** — `Promise` with `data: { transactions: MeldTransaction[]; count: number; remaining: number; totalCount: number }`. #### getRetailTransaction ```typescript theme={null} public async getRetailTransaction(id: string): Promise ``` | Parameter | Type | Description | | --------- | -------- | ------------------- | | `id` | `string` | Meld transaction ID | **Returns** — `Promise` with `data: { transaction: MeldTransaction }`. #### getRetailTransactionBySession ```typescript theme={null} public async getRetailTransactionBySession( sessionId: string ): Promise ``` | Parameter | Type | Description | | ----------- | -------- | -------------------------------------------------------------- | | `sessionId` | `string` | Meld session ID from `createRetailWidget` response (`data.id`) | **Returns** — `Promise` with `data: { transaction: MeldTransaction }`. #### getServiceProviders ```typescript theme={null} public async getServiceProviders( params?: MeldDiscoveryParams ): Promise ``` **Returns** — `Promise` with `data: MeldServiceProvider[]`. #### getCountries ```typescript theme={null} public async getCountries( params?: MeldDiscoveryParams ): Promise ``` **Returns** — `Promise` with `data: MeldCountry[]`. #### getFiatCurrencies ```typescript theme={null} public async getFiatCurrencies( params?: MeldDiscoveryParams ): Promise ``` **Returns** — `Promise` with `data: MeldFiatCurrency[]`. #### getCryptoCurrencies ```typescript theme={null} public async getCryptoCurrencies( params?: MeldDiscoveryParams ): Promise ``` **Returns** — `Promise` with `data: MeldCryptoCurrency[]`. #### getPaymentMethods ```typescript theme={null} public async getPaymentMethods( params?: MeldDiscoveryParams ): Promise ``` **Returns** — `Promise` with `data: MeldPaymentMethod[]`. #### getDefaults ```typescript theme={null} public async getDefaults( params?: MeldDiscoveryParams ): Promise ``` **Returns** — `Promise` with `data: MeldCountryDefault[]` (`countryCode`, `defaultCurrencyCode`, `defaultPaymentMethods`). #### getBuyLimits ```typescript theme={null} public async getBuyLimits( params?: MeldDiscoveryParams ): Promise ``` **Returns** — `Promise` with `data: MeldFiatCurrencyPurchaseLimit[]` (`currencyCode`, `minimumAmount`, `maximumAmount`, `defaultAmount`). #### getSellLimits ```typescript theme={null} public async getSellLimits( params?: MeldDiscoveryParams ): Promise ``` **Returns** — `Promise` with `data: MeldCryptoCurrencySellLimit[]` (`currencyCode`, `chainCode`, `minimumAmount`, `maximumAmount`, `defaultAmount`). #### getKycLimits ```typescript theme={null} public async getKycLimits( params?: MeldDiscoveryParams ): Promise ``` **Returns** — `Promise` with `data: MeldKycFiatLevel[]`. Each entry includes `currencyCode` and optional `level1`, `level2`, `level3` of type `MeldKycLimitTier` with `dailyLimit`, `weeklyLimit`, `monthlyLimit`, `yearlyLimit`, `transactionLimit`. *** ## Deprecated Methods The following methods are deprecated. Use `portal.request()` instead. ### ethEstimateGas Deprecated: Use `portal.request({ method: 'eth_estimateGas', ... })` instead. ```typescript theme={null} public async ethEstimateGas(chainId: string, transaction: EthereumTransaction): Promise ``` ### ethGasPrice Deprecated: Use `portal.request({ method: 'eth_gasPrice', ... })` instead. ```typescript theme={null} public async ethGasPrice(chainId: string): Promise ``` ### ethGetBalance Deprecated: Use `portal.request({ method: 'eth_getBalance', ... })` instead. ```typescript theme={null} public async ethGetBalance(chainId: string): Promise ``` ### ethSendTransaction Deprecated: Use `portal.request({ method: 'eth_sendTransaction', ... })` instead. ```typescript theme={null} public async ethSendTransaction(chainId: string, transaction: EthereumTransaction): Promise ``` ### ethSignTransaction Deprecated: Use `portal.request({ method: 'eth_signTransaction', ... })` instead. ```typescript theme={null} public async ethSignTransaction(chainId: string, transaction: EthereumTransaction): Promise ``` ### ethSignTypedData Deprecated: Use `portal.request({ method: 'eth_signTypedData', ... })` instead. ```typescript theme={null} public async ethSignTypedData(chainId: string, data: TypedData): Promise ``` ### ethSignTypedDataV3 Deprecated: Use `portal.request({ method: 'eth_signTypedData_v3', ... })` instead. ```typescript theme={null} public async ethSignTypedDataV3(chainId: string, data: TypedData): Promise ``` ### ethSignTypedDataV4 Deprecated: Use `portal.request({ method: 'eth_signTypedData_v4', ... })` instead. ```typescript theme={null} public async ethSignTypedDataV4(chainId: string, data: TypedData): Promise ``` ### personalSign Deprecated: Use `portal.request({ method: 'personal_sign', ... })` instead. ```typescript theme={null} public async personalSign(chainId: string, message: string): Promise ``` ### getBalances Deprecated: Use `portal.getAssets()` instead. ```typescript theme={null} public async getBalances(chainId: string): Promise ``` ### getNFTs Deprecated: Use `portal.getNFTAssets()` instead. ```typescript theme={null} public async getNFTs(chainId: string): Promise ``` ### simulateTransaction Deprecated: Use `portal.evaluateTransaction()` instead. ```typescript theme={null} public async simulateTransaction( chainId: string, transaction: SimulateTransactionParam ): Promise ``` ***