Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-15 22:43:52 +03:00
commit d6f9f59866
1729 changed files with 67614 additions and 9361 deletions

View file

@ -58,11 +58,6 @@ androidComponents {
variant.sources.java?.addGeneratedSourceDirectory(taskProvider, GenerateEnvironmentConfigTask::outputDir)
}
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Project */
@ -94,18 +89,19 @@ dependencies {
/** Coroutines */
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.coroutines.rx2)
implementation(deps.kotlin.datetime)
api(deps.kotlin.datetime)
api(deps.kotlin.serialization)
/** Logging */
/** Network */
implementation(deps.moshi)
api(deps.moshi)
implementation(deps.moshi.kotlin)
implementation(deps.moshi.adapters)
implementation(deps.moshi.adapters.ext)
implementation(deps.okHttp)
api(deps.okHttp)
implementation(deps.okHttp.prettyLogging)
implementation(deps.retrofit)
api(deps.retrofit)
implementation(deps.retrofit.moshi)
ksp(deps.moshi.kotlin.codegen)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
@ -124,11 +120,10 @@ dependencies {
releaseImplementation(deps.chuckerStub)
/** Local storages */
implementation(deps.androidx.datastore)
api(deps.androidx.datastore)
implementation(deps.room.runtime)
implementation(deps.room.ktx)
ksp(deps.room.compiler)
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
}

View file

@ -2,11 +2,11 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "aafa8b51b5a5a32d0ec2b0720cec6c1e",
"identityHash": "442ac578743a8b624777711cf49c77e2",
"entities": [
{
"tableName": "express_provider",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `icon_url` TEXT NOT NULL, `provider_url` TEXT NOT NULL, PRIMARY KEY(`id`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `image_large` TEXT NOT NULL, `image_small` TEXT NOT NULL, `terms_of_use` TEXT, `privacy_policy` TEXT, `is_recommended` INTEGER NOT NULL, `slippage` TEXT, `is_exchange_only_within_single_address` INTEGER NOT NULL, `is_extra_id_supported` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
@ -21,16 +21,55 @@
"notNull": true
},
{
"fieldPath": "iconUrl",
"columnName": "icon_url",
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "providerUrl",
"columnName": "provider_url",
"fieldPath": "imageLarge",
"columnName": "image_large",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imageSmall",
"columnName": "image_small",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "termsOfUse",
"columnName": "terms_of_use",
"affinity": "TEXT"
},
{
"fieldPath": "privacyPolicy",
"columnName": "privacy_policy",
"affinity": "TEXT"
},
{
"fieldPath": "isRecommended",
"columnName": "is_recommended",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "slippage",
"columnName": "slippage",
"affinity": "TEXT"
},
{
"fieldPath": "isExchangeOnlyWithinSingleAddress",
"columnName": "is_exchange_only_within_single_address",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isExtraIdSupported",
"columnName": "is_extra_id_supported",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
@ -38,13 +77,11 @@
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
},
{
"tableName": "express_exchange",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tx_id` TEXT NOT NULL, `owner_address` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `status` TEXT NOT NULL, `to_is_actual` INTEGER NOT NULL DEFAULT 0, `payin_hash` TEXT, `payout_hash` TEXT, `external_tx_id` TEXT, `external_tx_url` TEXT, `rate_type` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `from_network` TEXT NOT NULL, `from_token_id` TEXT, `from_raw_amount` TEXT NOT NULL, `from_decimals` INTEGER NOT NULL, `to_network` TEXT NOT NULL, `to_token_id` TEXT, `to_raw_amount` TEXT NOT NULL, `to_decimals` INTEGER NOT NULL, `refund_network` TEXT, `refund_token_id` TEXT, `refund_raw_amount` TEXT, `refund_decimals` INTEGER, `refund_hash` TEXT, PRIMARY KEY(`tx_id`), FOREIGN KEY(`provider_id`) REFERENCES `express_provider`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT )",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tx_id` TEXT NOT NULL, `owner_address` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `from_address` TEXT, `payin_address` TEXT NOT NULL, `payin_extra_id` TEXT, `payout_address` TEXT NOT NULL, `refund_address` TEXT, `refund_extra_id` TEXT, `rate_type` TEXT NOT NULL, `status` TEXT NOT NULL, `external_tx_id` TEXT, `external_tx_url` TEXT, `payin_hash` TEXT, `payout_hash` TEXT, `refund_network` TEXT, `refund_contract_address` TEXT, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `pay_till` TEXT, `average_duration` INTEGER, `from_contract_address` TEXT NOT NULL, `from_network` TEXT NOT NULL, `from_decimals` INTEGER NOT NULL, `from_amount` TEXT NOT NULL, `from_actual_amount` TEXT, `to_contract_address` TEXT NOT NULL, `to_network` TEXT NOT NULL, `to_decimals` INTEGER NOT NULL, `to_amount` TEXT NOT NULL, `to_actual_amount` TEXT, PRIMARY KEY(`tx_id`))",
"fields": [
{
"fieldPath": "txId",
@ -65,41 +102,36 @@
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"fieldPath": "fromAddress",
"columnName": "from_address",
"affinity": "TEXT"
},
{
"fieldPath": "payinAddress",
"columnName": "payin_address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toIsActual",
"columnName": "to_is_actual",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
"fieldPath": "payinExtraId",
"columnName": "payin_extra_id",
"affinity": "TEXT"
},
{
"fieldPath": "payinHash",
"columnName": "payin_hash",
"fieldPath": "payoutAddress",
"columnName": "payout_address",
"affinity": "TEXT",
"notNull": false
"notNull": true
},
{
"fieldPath": "payoutHash",
"columnName": "payout_hash",
"affinity": "TEXT",
"notNull": false
"fieldPath": "refundAddress",
"columnName": "refund_address",
"affinity": "TEXT"
},
{
"fieldPath": "externalTxId",
"columnName": "external_tx_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "externalTxUrl",
"columnName": "external_tx_url",
"affinity": "TEXT",
"notNull": false
"fieldPath": "refundExtraId",
"columnName": "refund_extra_id",
"affinity": "TEXT"
},
{
"fieldPath": "rateType",
@ -107,16 +139,68 @@
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "externalTxId",
"columnName": "external_tx_id",
"affinity": "TEXT"
},
{
"fieldPath": "externalTxUrl",
"columnName": "external_tx_url",
"affinity": "TEXT"
},
{
"fieldPath": "payinHash",
"columnName": "payin_hash",
"affinity": "TEXT"
},
{
"fieldPath": "payoutHash",
"columnName": "payout_hash",
"affinity": "TEXT"
},
{
"fieldPath": "refundNetwork",
"columnName": "refund_network",
"affinity": "TEXT"
},
{
"fieldPath": "refundContractAddress",
"columnName": "refund_contract_address",
"affinity": "TEXT"
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "payTill",
"columnName": "pay_till",
"affinity": "TEXT"
},
{
"fieldPath": "averageDuration",
"columnName": "average_duration",
"affinity": "INTEGER"
},
{
"fieldPath": "from.contractAddress",
"columnName": "from_contract_address",
"affinity": "TEXT",
"notNull": true
},
{
@ -125,18 +209,6 @@
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "from.tokenId",
"columnName": "from_token_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "from.rawAmount",
"columnName": "from_raw_amount",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "from.decimals",
"columnName": "from_decimals",
@ -144,20 +216,25 @@
"notNull": true
},
{
"fieldPath": "to.network",
"columnName": "to_network",
"fieldPath": "from.amount",
"columnName": "from_amount",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "to.tokenId",
"columnName": "to_token_id",
"affinity": "TEXT",
"notNull": false
"fieldPath": "from.actualAmount",
"columnName": "from_actual_amount",
"affinity": "TEXT"
},
{
"fieldPath": "to.rawAmount",
"columnName": "to_raw_amount",
"fieldPath": "to.contractAddress",
"columnName": "to_contract_address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "to.network",
"columnName": "to_network",
"affinity": "TEXT",
"notNull": true
},
@ -168,34 +245,15 @@
"notNull": true
},
{
"fieldPath": "refund.network",
"columnName": "refund_network",
"fieldPath": "to.amount",
"columnName": "to_amount",
"affinity": "TEXT",
"notNull": false
"notNull": true
},
{
"fieldPath": "refund.tokenId",
"columnName": "refund_token_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "refund.rawAmount",
"columnName": "refund_raw_amount",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "refund.decimals",
"columnName": "refund_decimals",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "refund.hash",
"columnName": "refund_hash",
"affinity": "TEXT",
"notNull": false
"fieldPath": "to.actualAmount",
"columnName": "to_actual_amount",
"affinity": "TEXT"
}
],
"primaryKey": {
@ -206,64 +264,33 @@
},
"indices": [
{
"name": "index_express_exchange_owner_address_from_network_updated_at",
"name": "index_express_exchange_owner_address_from_network_from_contract_address_created_at",
"unique": false,
"columnNames": [
"owner_address",
"from_network",
"updated_at"
"from_contract_address",
"created_at"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_owner_address_from_network_updated_at` ON `${TABLE_NAME}` (`owner_address`, `from_network`, `updated_at`)"
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_owner_address_from_network_from_contract_address_created_at` ON `${TABLE_NAME}` (`owner_address`, `from_network`, `from_contract_address`, `created_at`)"
},
{
"name": "index_express_exchange_owner_address_payin_hash",
"name": "index_express_exchange_to_network_to_contract_address_created_at",
"unique": false,
"columnNames": [
"owner_address",
"payin_hash"
"to_network",
"to_contract_address",
"created_at"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_owner_address_payin_hash` ON `${TABLE_NAME}` (`owner_address`, `payin_hash`)"
},
{
"name": "index_express_exchange_owner_address_payout_hash",
"unique": false,
"columnNames": [
"owner_address",
"payout_hash"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_owner_address_payout_hash` ON `${TABLE_NAME}` (`owner_address`, `payout_hash`)"
},
{
"name": "index_express_exchange_owner_address_refund_hash",
"unique": false,
"columnNames": [
"owner_address",
"refund_hash"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_owner_address_refund_hash` ON `${TABLE_NAME}` (`owner_address`, `refund_hash`)"
}
],
"foreignKeys": [
{
"table": "express_provider",
"onDelete": "RESTRICT",
"onUpdate": "NO ACTION",
"columns": [
"provider_id"
],
"referencedColumns": [
"id"
]
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_exchange_to_network_to_contract_address_created_at` ON `${TABLE_NAME}` (`to_network`, `to_contract_address`, `created_at`)"
}
]
},
{
"tableName": "express_onramp",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tx_id` TEXT NOT NULL, `owner_address` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `status` TEXT NOT NULL, `from_currency_code` TEXT NOT NULL, `from_amount` TEXT NOT NULL, `to_network` TEXT NOT NULL, `to_token_id` TEXT, `to_expected_raw_amount` TEXT NOT NULL, `to_actual_raw_amount` TEXT, `to_decimals` INTEGER NOT NULL, `payout_hash` TEXT, `external_tx_id` TEXT, `external_tx_url` TEXT, `rate_type` TEXT NOT NULL, `fail_reason` TEXT, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `refund_currency_code` TEXT, `refund_amount` TEXT, PRIMARY KEY(`tx_id`), FOREIGN KEY(`provider_id`) REFERENCES `express_provider`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT )",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tx_id` TEXT NOT NULL, `owner_address` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `payout_address` TEXT NOT NULL, `status` TEXT NOT NULL, `fail_reason` TEXT, `external_tx_id` TEXT, `external_tx_url` TEXT, `payout_hash` TEXT, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `from_currency_code` TEXT NOT NULL, `from_amount` TEXT NOT NULL, `from_precision` INTEGER NOT NULL, `payment_method` TEXT NOT NULL, `country_code` TEXT NOT NULL, `to_contract_address` TEXT NOT NULL, `to_network` TEXT NOT NULL, `to_decimals` INTEGER NOT NULL, `to_amount` TEXT, `to_actual_amount` TEXT, PRIMARY KEY(`tx_id`))",
"fields": [
{
"fieldPath": "txId",
@ -283,12 +310,50 @@
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "payoutAddress",
"columnName": "payout_address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "failReason",
"columnName": "fail_reason",
"affinity": "TEXT"
},
{
"fieldPath": "externalTxId",
"columnName": "external_tx_id",
"affinity": "TEXT"
},
{
"fieldPath": "externalTxUrl",
"columnName": "external_tx_url",
"affinity": "TEXT"
},
{
"fieldPath": "payoutHash",
"columnName": "payout_hash",
"affinity": "TEXT"
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fromCurrencyCode",
"columnName": "from_currency_code",
@ -302,88 +367,50 @@
"notNull": true
},
{
"fieldPath": "toNetwork",
"fieldPath": "fromPrecision",
"columnName": "from_precision",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "paymentMethod",
"columnName": "payment_method",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "countryCode",
"columnName": "country_code",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "to.contractAddress",
"columnName": "to_contract_address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "to.network",
"columnName": "to_network",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toTokenId",
"columnName": "to_token_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "toExpectedRawAmount",
"columnName": "to_expected_raw_amount",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toActualRawAmount",
"columnName": "to_actual_raw_amount",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "toDecimals",
"fieldPath": "to.decimals",
"columnName": "to_decimals",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "payoutHash",
"columnName": "payout_hash",
"affinity": "TEXT",
"notNull": false
"fieldPath": "to.amount",
"columnName": "to_amount",
"affinity": "TEXT"
},
{
"fieldPath": "externalTxId",
"columnName": "external_tx_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "externalTxUrl",
"columnName": "external_tx_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "rateType",
"columnName": "rate_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "failReason",
"columnName": "fail_reason",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "refund.currencyCode",
"columnName": "refund_currency_code",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "refund.amount",
"columnName": "refund_amount",
"affinity": "TEXT",
"notNull": false
"fieldPath": "to.actualAmount",
"columnName": "to_actual_amount",
"affinity": "TEXT"
}
],
"primaryKey": {
@ -394,46 +421,64 @@
},
"indices": [
{
"name": "index_express_onramp_owner_address_to_network_updated_at",
"name": "index_express_onramp_owner_address_to_network_to_contract_address_created_at",
"unique": false,
"columnNames": [
"owner_address",
"to_network",
"updated_at"
"to_contract_address",
"created_at"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_onramp_owner_address_to_network_updated_at` ON `${TABLE_NAME}` (`owner_address`, `to_network`, `updated_at`)"
},
{
"name": "index_express_onramp_owner_address_payout_hash",
"unique": false,
"columnNames": [
"owner_address",
"payout_hash"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_onramp_owner_address_payout_hash` ON `${TABLE_NAME}` (`owner_address`, `payout_hash`)"
}
],
"foreignKeys": [
{
"table": "express_provider",
"onDelete": "RESTRICT",
"onUpdate": "NO ACTION",
"columns": [
"provider_id"
],
"referencedColumns": [
"id"
]
"createSql": "CREATE INDEX IF NOT EXISTS `index_express_onramp_owner_address_to_network_to_contract_address_created_at` ON `${TABLE_NAME}` (`owner_address`, `to_network`, `to_contract_address`, `created_at`)"
}
]
},
{
"tableName": "express_sync_state",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` TEXT NOT NULL, `address` TEXT NOT NULL, `is_initial_completed` INTEGER NOT NULL, `after_cursor` TEXT, `delta_cursor` TEXT, PRIMARY KEY(`type`, `address`))",
"fields": [
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "address",
"columnName": "address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isInitialCompleted",
"columnName": "is_initial_completed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "afterCursor",
"columnName": "after_cursor",
"affinity": "TEXT"
},
{
"fieldPath": "deltaCursor",
"columnName": "delta_cursor",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"type",
"address"
]
}
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'aafa8b51b5a5a32d0ec2b0720cec6c1e')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '442ac578743a8b624777711cf49c77e2')"
]
}
}

View file

@ -0,0 +1,64 @@
package com.tangem.datasource.api.auth
import com.tangem.datasource.api.auth.models.request.AuthApiRequest
import com.tangem.datasource.api.auth.models.request.NonceApiRequest
import com.tangem.datasource.api.auth.models.request.RefreshApiRequest
import com.tangem.datasource.api.auth.models.request.RegisterApiRequest
import com.tangem.datasource.api.auth.models.response.NonceApiResponse
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
import com.tangem.datasource.api.common.response.ApiResponse
import retrofit2.http.Body
import retrofit2.http.POST
/**
* Tangem Auth Service API (JWT session tokens / DPoP interceptor / refresh rotation)
*/
interface AuthApi {
/**
* Request device registration nonce.
*
* Generates a nonce bound to the device public key for the device registration flow.
*/
@POST("api/v1/auth/nonce/device")
suspend fun requestDeviceNonce(@Body request: NonceApiRequest): ApiResponse<NonceApiResponse>
/**
* Register device.
*
* Registers a new device using its hardware-backed public key and issues the initial
* session token pair. Called once per app install.
*/
@POST("api/v1/auth/register")
@RequiresDpopProof
suspend fun register(@Body request: RegisterApiRequest): ApiResponse<TokenApiResponse>
/**
* Request authentication nonce.
*
* Generates a nonce bound to the device public key for the authentication flow.
*/
@POST("api/v1/auth/nonce/auth")
suspend fun requestAuthNonce(@Body request: NonceApiRequest): ApiResponse<NonceApiResponse>
/**
* Authenticate device.
*
* Authenticates a previously registered device using a device-key signature. Issues a new
* JWT access token with bound `walletIds[]` and risk tier. All subsequent auth after
* registration uses this endpoint.
*/
@POST("api/v1/auth/authenticate")
suspend fun authenticate(@Body request: AuthApiRequest): ApiResponse<TokenApiResponse>
/**
* Refresh tokens.
*
* Rotates the refresh token and issues a new access token. Uses refresh-token rotation
* with family-based reuse detection replaying a consumed token revokes the entire token
* family (SR-8). Sender-constraint is verified via the DPoP-proof header (`cnf.jkt`).
*/
@POST("api/v1/auth/refresh")
@RequiresDpopProof
suspend fun refresh(@Body request: RefreshApiRequest): ApiResponse<TokenApiResponse>
}

View file

@ -0,0 +1,56 @@
package com.tangem.datasource.api.auth
/**
* Marks a Retrofit endpoint as needing a DPoP proof header ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449))
* but **not** automatic refresh-on-401.
*
* Read at runtime by the DPoP authorization interceptor: methods carrying this annotation
* (or the umbrella [RequiresSessionAuth]) receive `Authorization: DPoP <access-token>` (when
* available) + `DPoP: <proof-jwt>` headers.
*
* Use this on endpoints that are themselves part of the refresh flow e.g. `/auth/refresh`
* to prevent the session authenticator from re-entering refresh on a 401 (which would deadlock
* the single-flight refresh mutex).
*
* For ordinary session-protected endpoints, prefer the combined [RequiresSessionAuth].
*/
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RequiresDpopProof
/**
* Marks a Retrofit endpoint as eligible for automatic session-token refresh on 401/403.
*
* Read at runtime by the session authenticator: methods carrying this annotation (or the
* umbrella [RequiresSessionAuth]) trigger `SessionTokenRefresher.refresh()` + a single retry
* with new tokens when the server responds with 401/403.
*
* Important: this annotation alone does **not** instruct the DPoP interceptor to add headers
* on the initial outgoing request. The retry built by the session authenticator after a
* successful refresh, however, always carries fresh `Authorization` / `DPoP` headers that
* happens regardless of which annotation gated the refresh.
*
* Rare in isolation proof and refresh-on-401 almost always travel together. Prefer the
* combined [RequiresSessionAuth] unless you have a concrete reason to omit proof on send.
*/
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RequiresSessionRefresh
/**
* Marks a Retrofit endpoint as fully session-protected ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)).
*
* Combines [RequiresDpopProof] (outgoing `Authorization: DPoP <access-token>` + `DPoP: <proof-jwt>`
* headers via the DPoP authorization interceptor) and [RequiresSessionRefresh] (automatic refresh +
* single retry on 401/403 via the session authenticator).
*
* Default choice for normal session-protected endpoints. Use the two specialised annotations only
* when you need exactly one of the behaviours typically `@RequiresDpopProof` on endpoints inside
* the refresh flow itself (`/auth/refresh`) to prevent recursion.
*
* Mirrors the per-operation `security` blocks in the backend OpenAPI contract; follows the same
* on-method annotation pattern as `@ReadTimeout` / `@ConnectTimeout`.
*/
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RequiresSessionAuth

View file

@ -0,0 +1,26 @@
package com.tangem.datasource.api.auth.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Authentication request — authenticates a previously registered device. */
@JsonClass(generateAdapter = true)
data class AuthApiRequest(
/** Signed authentication payload. */
@Json(name = "payload") val payload: AuthenticationPayload,
/** EC signature over the authentication payload, signed by the device private key (Base64). */
@Json(name = "signature") val signature: String,
)
/** Signed authentication payload — the data that is signed by the device private key. */
@JsonClass(generateAdapter = true)
data class AuthenticationPayload(
/** Base64-encoded EC public key of the device (e.g. `MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...`). */
@Json(name = "devicePublicKey") val devicePublicKey: String,
/** Deciphered nonce value from the nonce endpoint. */
@Json(name = "nonce") val nonce: String,
/** Platform attestation token (Play Integrity / App Attest). */
@Json(name = "attestationToken") val attestationToken: String?,
/** Client-reported device metadata. */
@Json(name = "metadata") val metadata: DeviceMetadata,
)

View file

@ -0,0 +1,26 @@
package com.tangem.datasource.api.auth.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Client-reported device metadata, included in both [AuthenticationPayload] and [RegisterPayload].
* Mirrors the `DeviceMetadata` schema in the backend OpenAPI contract.
*/
@JsonClass(generateAdapter = true)
data class DeviceMetadata(
/** Device hardware model (e.g. `iPhone 15 Pro`). */
@Json(name = "deviceModel") val deviceModel: String?,
/** Operating system (`android` / `ios`). */
@Json(name = "os") val os: String,
/** OS version string (e.g. `17.4.1`). */
@Json(name = "osVersion") val osVersion: String?,
/** Application version (e.g. `5.8.0`). */
@Json(name = "appVersion") val appVersion: String?,
/** User-Agent header (e.g. `Tangem/5.8.0 (iPhone; iOS 17.4.1; Scale/3.00)`). */
@Json(name = "userAgent") val userAgent: String?,
/** Client locale (e.g. `en-US`). */
@Json(name = "locale") val locale: String?,
/** Client timezone (e.g. `Europe/Moscow`). */
@Json(name = "timezone") val timezone: String?,
)

View file

@ -0,0 +1,11 @@
package com.tangem.datasource.api.auth.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Request body for nonce generation (auth, upgrade, wallet flows). */
@JsonClass(generateAdapter = true)
data class NonceApiRequest(
/** Base64-encoded EC public key of the device (e.g. `MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...`). */
@Json(name = "devicePublicKey") val devicePublicKey: String,
)

View file

@ -0,0 +1,11 @@
package com.tangem.datasource.api.auth.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Token refresh request. */
@JsonClass(generateAdapter = true)
data class RefreshApiRequest(
/** Refresh token from a previous token response. */
@Json(name = "refreshToken") val refreshToken: String,
)

View file

@ -0,0 +1,31 @@
package com.tangem.datasource.api.auth.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Registration request registers a new device and establishes initial trust.
*
* Posted to `POST /api/v1/auth/register`; on success the server returns
* [com.tangem.datasource.api.auth.models.response.TokenApiResponse] (the initial session token pair).
*/
@JsonClass(generateAdapter = true)
data class RegisterApiRequest(
/** Signed registration payload. */
@Json(name = "payload") val payload: RegisterPayload,
/** EC signature over the registration payload, signed by the device private key (Base64). */
@Json(name = "signature") val signature: String,
)
/** Signed registration payload — the data that is signed by the device private key. */
@JsonClass(generateAdapter = true)
data class RegisterPayload(
/** Base64-encoded EC public key of the device. */
@Json(name = "devicePublicKey") val devicePublicKey: String,
/** Deciphered nonce value from the `/api/v1/auth/nonce/device` endpoint. */
@Json(name = "nonce") val nonce: String,
/** Platform attestation token (Play Integrity / App Attest). Optional; backend accepts `null`. */
@Json(name = "attestationToken") val attestationToken: String?,
/** Client-reported device metadata. */
@Json(name = "metadata") val metadata: DeviceMetadata,
)

View file

@ -0,0 +1,13 @@
package com.tangem.datasource.api.auth.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Ciphered nonce response. */
@JsonClass(generateAdapter = true)
data class NonceApiResponse(
/** RSA-OAEP ciphered nonce value (Base64). */
@Json(name = "cipheredNonce") val cipheredNonce: String,
/** Nonce expiration timestamp (ISO-8601). */
@Json(name = "expiresAt") val expiresAt: String,
)

View file

@ -0,0 +1,19 @@
package com.tangem.datasource.api.auth.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Token response — contains JWT access token and optional refresh token. */
@JsonClass(generateAdapter = true)
data class TokenApiResponse(
/** JWT access token (HMAC-SHA256 signed). */
@Json(name = "accessToken") val accessToken: String,
/** Access token expiration timestamp (ISO-8601). */
@Json(name = "accessTokenExpiresAt") val accessTokenExpiresAt: String,
/** Refresh token for token rotation. `null` for ORANGE tier (requires device challenge each time). */
@Json(name = "refreshToken") val refreshToken: String?,
/** Refresh token expiration timestamp (ISO-8601). `null` iff [refreshToken] is `null`. */
@Json(name = "refreshTokenExpiresAt") val refreshTokenExpiresAt: String?,
/** List of wallet IDs bound to this device. */
@Json(name = "walletIds") val walletIds: List<String>,
)

View file

@ -0,0 +1,20 @@
package com.tangem.datasource.api.auth.qualifier
import javax.inject.Qualifier
/**
* Marks the OkHttp [okhttp3.Interceptor] that attaches Tangem Auth Service session credentials
* (`Authorization: DPoP <access-token>` + `DPoP: <proof-jwt>`) to outgoing requests. The actual
* binding lives in `libs:auth` so this module does not depend on the auth library; Hilt assembles
* the binding at the `:app` level.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class SessionAuthInterceptor
/**
* Marks the OkHttp [okhttp3.Authenticator] that rotates session tokens on 401/403 responses.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class SessionAuthAuthenticator

View file

@ -33,6 +33,7 @@ sealed class ApiConfig {
News,
GaslessTxService,
SurveySparrow,
Auth,
}
private fun initializeId(): ID {
@ -49,6 +50,7 @@ sealed class ApiConfig {
is News -> ID.News
is GaslessTxService -> ID.GaslessTxService
is SurveySparrow -> ID.SurveySparrow
is Auth -> ID.Auth
}
}

View file

@ -0,0 +1,48 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
/**
* Tangem Auth Service [ApiConfig] endpoints for device registration, authentication,
* nonce issuance, refresh token rotation, and JWKS publication.
*/
internal class Auth : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
createDevEnvironment(),
createProdEnvironment(),
)
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE,
DEBUG_BUILD_TYPE,
INTERNAL_BUILD_TYPE,
-> ApiEnvironment.DEV
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
}
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = DEV_BASE_URL,
headers = emptyMap(),
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = PROD_BASE_URL,
headers = emptyMap(),
)
private companion object {
private const val DEV_BASE_URL = "[REDACTED_ENV_URL]"
private const val PROD_BASE_URL = "https://authentication.tangem.org/"
}
}

View file

@ -20,11 +20,13 @@ internal class GaslessTxService(
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
createProdEnvironment(),
createDevEnvironment(),
createMockedEnvironment(),
)
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE,
-> ApiEnvironment.MOCK
DEBUG_BUILD_TYPE,
-> ApiEnvironment.DEV
INTERNAL_BUILD_TYPE,
@ -47,6 +49,12 @@ internal class GaslessTxService(
headers = createHeaders(ApiEnvironment.DEV),
)
private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = MOCK_BASE_URL,
headers = createHeaders(ApiEnvironment.MOCK),
)
private fun createHeaders(environment: ApiEnvironment) = buildMap {
putAll(RequestHeader.AppVersionPlatformHeaders(appInfoProvider).values)
put(
@ -60,5 +68,6 @@ internal class GaslessTxService(
private companion object {
private const val PROD_BASE_URL = "https://gasless.tangem.org/"
private const val DEV_BASE_URL = "[REDACTED_ENV_URL]"
private const val MOCK_BASE_URL = "[REDACTED_ENV_URL]"
}
}

View file

@ -78,7 +78,7 @@ interface TangemExpressApi {
@Header("user-id") userWalletId: String,
@Header("refcode") refCode: String?,
@Query("txId") txId: String,
): ApiResponse<ExchangeStatusResponse>
): ApiResponse<ExchangeItemResponse>
@POST("exchange-sent")
suspend fun exchangeSent(
@ -87,10 +87,19 @@ interface TangemExpressApi {
@Body body: ExchangeSentRequestBody,
): ApiResponse<ExchangeSentResponseBody>
@GET("exchange/history")
@GET("history/exchange")
suspend fun getHistory(
@Query("wallet_address") walletAddress: String,
@Query("cursor") cursor: String?,
@Header("user-id") userWalletId: String,
@Query("fromAddress") fromAddress: String,
@Query("afterCursor") cursor: String?,
@Query("limit") limit: Int = 100,
): ApiResponse<ExchangeHistoryResponse>
@GET("history/delta/exchange")
suspend fun getHistoryDelta(
@Header("user-id") userWalletId: String,
@Query("fromAddress") fromAddress: String,
@Query("beforeCursor") cursor: String?,
@Query("limit") limit: Int = 100,
): ApiResponse<ExchangeHistoryDeltaResponse>
}

View file

@ -5,81 +5,16 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ExchangeHistoryResponse(
@Json(name = "data")
val data: List<ExchangeRecord>,
@Json(name = "next_cursor")
val nextCursor: String,
@Json(name = "has_more")
val hasMore: Boolean,
) {
@Json(name = "items")
val items: List<ExchangeItemResponse>,
@Json(name = "pagination")
val pagination: ExpressPagination,
)
@JsonClass(generateAdapter = true)
data class ExchangeRecord(
@Json(name = "tx_id")
val txId: String,
@Json(name = "status")
val status: String,
@Json(name = "provider")
val provider: Provider,
@Json(name = "from")
val from: AssetRef,
@Json(name = "to")
val to: AssetRef,
@Json(name = "payin_hash")
val payinHash: String?,
@Json(name = "payout_hash")
val payoutHash: String?,
@Json(name = "external_tx_id")
val externalTxId: String?,
@Json(name = "external_tx_url")
val externalTxUrl: String?,
@Json(name = "refund")
val refund: RefundInfo?,
@Json(name = "rate_type")
val rateType: String,
@Json(name = "created_at")
val createdAt: Long,
@Json(name = "updated_at")
val updatedAt: Long,
)
@JsonClass(generateAdapter = true)
data class Provider(
@Json(name = "id")
val id: String,
@Json(name = "name")
val name: String,
@Json(name = "icon_url")
val iconUrl: String,
@Json(name = "provider_url")
val providerUrl: String,
)
@JsonClass(generateAdapter = true)
data class AssetRef(
@Json(name = "network")
val network: String,
@Json(name = "token_id")
val tokenId: String?,
@Json(name = "raw_amount")
val rawAmount: String,
@Json(name = "decimals")
val decimals: Int,
@Json(name = "is_actual")
val isActual: Boolean?,
)
@JsonClass(generateAdapter = true)
data class RefundInfo(
@Json(name = "network")
val network: String,
@Json(name = "token_id")
val tokenId: String?,
@Json(name = "raw_amount")
val rawAmount: String,
@Json(name = "decimals")
val decimals: Int,
@Json(name = "hash")
val hash: String?,
)
}
@JsonClass(generateAdapter = true)
data class ExchangeHistoryDeltaResponse(
@Json(name = "items")
val items: List<ExchangeItemResponse>,
@Json(name = "pagination")
val pagination: ExpressPaginationDelta,
)

View file

@ -0,0 +1,117 @@
package com.tangem.datasource.api.express.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ExchangeItemResponse(
// region transaction info
@Json(name = "txId")
val txId: String,
@Json(name = "providerId")
val providerId: String,
/**
* Address from which the `from` assets were taken for the exchange. Optional because the very first
* app versions did not send it; for newer versions it can be considered effectively mandatory.
*/
@Json(name = "fromAddress")
val fromAddress: String?,
/** Address to which the source assets were transferred for the exchange */
@Json(name = "payinAddress")
val payinAddress: String,
/** Extra ID used for the pay-in transaction */
@Json(name = "payinExtraId")
val payinExtraId: String?,
/** Address that received the target assets */
@Json(name = "payoutAddress")
val payoutAddress: String,
/** Refund destination address */
@Json(name = "refundAddress")
val refundAddress: String?,
/** Extra ID used for refunds */
@Json(name = "refundExtraId")
val refundExtraId: String?,
/** Exchange rate type (e.g. float, fixed) */
@Json(name = "rateType")
val rateType: String,
/**
* Raw backend status string, kept unparsed so a new value never breaks deserialization.
* Typed view: [com.tangem.domain.express.models.ExpressExchangeStatus].
*/
@Json(name = "status")
val status: String,
/** External transaction ID (CEX only) */
@Json(name = "externalTxId")
val externalTxId: String?,
/** URL to view the transaction details (CEX only) */
@Json(name = "externalTxUrl")
val externalTxUrl: String?,
/** Blockchain hash of the pay-in transaction */
@Json(name = "payinHash")
val payinHash: String?,
/** Blockchain hash of the payout transaction */
@Json(name = "payoutHash")
val payoutHash: String?,
/** Network used for the refund transaction */
@Json(name = "refundNetwork")
val refundNetwork: String?,
/** Refunded token contract address */
@Json(name = "refundContractAddress")
val refundContractAddress: String?,
/** Transaction creation timestamp in ISO-8601 format */
@Json(name = "createdAt")
val createdAt: String,
/** Transaction last-update timestamp in ISO-8601 format */
@Json(name = "updatedAt")
val updatedAt: String,
/** Pay-in expiration timestamp in ISO-8601 format */
@Json(name = "payTill")
val payTill: String?,
/** Average provider exchange duration in seconds */
@Json(name = "averageDuration")
val averageDuration: Long?,
// endregion
// region fromAsset info
@Json(name = "fromContractAddress")
val fromContractAddress: String,
@Json(name = "fromNetwork")
val fromNetwork: String,
@Json(name = "fromDecimals")
val fromDecimals: Int,
@Json(name = "fromAmount")
val fromAmount: String,
// endregion
// region toAsset info
@Json(name = "toContractAddress")
val toContractAddress: String,
@Json(name = "toNetwork")
val toNetwork: String,
@Json(name = "toDecimals")
val toDecimals: Int,
@Json(name = "toAmount")
val toAmount: String,
@Json(name = "toActualAmount")
val toActualAmount: String?,
// endregion
)

View file

@ -0,0 +1,22 @@
package com.tangem.datasource.api.express.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ExpressPagination(
@Json(name = "endCursor")
val endCursor: String?,
@Json(name = "startDeltaCursor")
val startDeltaCursor: String?,
@Json(name = "hasMore")
val hasMore: Boolean,
)
@JsonClass(generateAdapter = true)
data class ExpressPaginationDelta(
@Json(name = "startCursor")
val startCursor: String?,
@Json(name = "hasMore")
val hasMore: Boolean,
)

View file

@ -3,9 +3,10 @@ package com.tangem.datasource.api.onramp
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.onramp.models.request.OnrampPairsRequest
import com.tangem.datasource.api.onramp.models.response.OnrampDataResponse
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaResponse
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryResponse
import com.tangem.datasource.api.onramp.models.response.OnrampQuoteResponse
import com.tangem.datasource.api.onramp.models.response.OnrampStatusResponse
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
import com.tangem.datasource.api.onramp.models.response.model.OnrampCountryDTO
import com.tangem.datasource.api.onramp.models.response.model.OnrampCurrencyDTO
import com.tangem.datasource.api.onramp.models.response.model.OnrampPairDTO
@ -86,12 +87,21 @@ interface OnrampApi {
@Header("user-id") userWalletId: String,
@Header("refcode") refCode: String?,
@Query("txId") txId: String,
): ApiResponse<OnrampStatusResponse>
): ApiResponse<OnrampItemResponse>
@GET("onramp/history")
@GET("history/onramp")
suspend fun getHistory(
@Query("wallet_address") walletAddress: String,
@Query("cursor") cursor: String?,
@Header("user-id") userWalletId: String,
@Query("payoutAddress") payoutAddress: String,
@Query("afterCursor") afterCursor: String?,
@Query("limit") limit: Int = 100,
): ApiResponse<OnrampHistoryResponse>
@GET("history/delta/onramp")
suspend fun getHistoryDelta(
@Header("user-id") userWalletId: String,
@Query("payoutAddress") payoutAddress: String,
@Query("beforeCursor") cursor: String?,
@Query("limit") limit: Int = 100,
): ApiResponse<OnrampHistoryDeltaResponse>
}

View file

@ -2,86 +2,21 @@ package com.tangem.datasource.api.onramp.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.datasource.api.express.models.response.ExpressPagination
import com.tangem.datasource.api.express.models.response.ExpressPaginationDelta
@JsonClass(generateAdapter = true)
data class OnrampHistoryResponse(
@Json(name = "data")
val data: List<OnrampRecord>,
@Json(name = "next_cursor")
val nextCursor: String,
@Json(name = "has_more")
val hasMore: Boolean,
) {
@Json(name = "items")
val items: List<OnrampItemResponse>,
@Json(name = "pagination")
val pagination: ExpressPagination,
)
@JsonClass(generateAdapter = true)
data class OnrampRecord(
@Json(name = "tx_id")
val txId: String,
@Json(name = "status")
val status: String,
@Json(name = "provider")
val provider: Provider,
@Json(name = "from")
val from: FiatRef,
@Json(name = "to")
val to: OnrampAssetRef,
@Json(name = "payout_hash")
val payoutHash: String?,
@Json(name = "external_tx_id")
val externalTxId: String?,
@Json(name = "external_tx_url")
val externalTxUrl: String?,
@Json(name = "refund")
val refund: OnrampRefundInfo?,
@Json(name = "rate_type")
val rateType: String,
@Json(name = "fail_reason")
val failReason: String?,
@Json(name = "created_at")
val createdAt: Long,
@Json(name = "updated_at")
val updatedAt: Long,
)
@JsonClass(generateAdapter = true)
data class Provider(
@Json(name = "id")
val id: String,
@Json(name = "name")
val name: String,
@Json(name = "icon_url")
val iconUrl: String,
@Json(name = "provider_url")
val providerUrl: String,
)
@JsonClass(generateAdapter = true)
data class FiatRef(
@Json(name = "currency_code")
val currencyCode: String,
@Json(name = "amount")
val amount: String,
)
@JsonClass(generateAdapter = true)
data class OnrampAssetRef(
@Json(name = "network")
val network: String,
@Json(name = "token_id")
val tokenId: String?,
@Json(name = "expected_raw_amount")
val expectedRawAmount: String,
@Json(name = "actual_raw_amount")
val actualRawAmount: String?,
@Json(name = "decimals")
val decimals: Int,
)
@JsonClass(generateAdapter = true)
data class OnrampRefundInfo(
@Json(name = "currency_code")
val currencyCode: String,
@Json(name = "amount")
val amount: String,
)
}
@JsonClass(generateAdapter = true)
data class OnrampHistoryDeltaResponse(
@Json(name = "items")
val items: List<OnrampItemResponse>,
@Json(name = "pagination")
val pagination: ExpressPaginationDelta,
)

View file

@ -0,0 +1,82 @@
package com.tangem.datasource.api.onramp.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class OnrampItemResponse(
// region transaction info
@Json(name = "txId")
val txId: String,
@Json(name = "providerId")
val providerId: String,
/** Address that received the target assets */
@Json(name = "payoutAddress")
val payoutAddress: String,
/**
* Raw backend status string, kept unparsed so a new value never breaks deserialization.
* Typed view: [com.tangem.domain.express.models.ExpressOnrampStatus].
*/
@Json(name = "status")
val status: String,
/** Failure reason reported by the provider */
@Json(name = "failReason")
val failReason: String?,
/** External transaction ID reported by the provider in the webhook */
@Json(name = "externalTxId")
val externalTxId: String?,
/** URL to view the transaction details on the provider side (not provided by all providers) */
@Json(name = "externalTxUrl")
val externalTxUrl: String?,
/** Blockchain hash of the payout transaction */
@Json(name = "payoutHash")
val payoutHash: String?,
/** Transaction creation timestamp in ISO-8601 format */
@Json(name = "createdAt")
val createdAt: String,
/** Transaction last-update timestamp in ISO-8601 format */
@Json(name = "updatedAt")
val updatedAt: String,
// endregion
// region fromAsset (fiat) info
@Json(name = "fromCurrencyCode")
val fromCurrencyCode: String,
@Json(name = "fromAmount")
val fromAmount: String,
@Json(name = "fromPrecision")
val fromPrecision: Int,
// endregion
// region toAsset info
@Json(name = "toContractAddress")
val toContractAddress: String,
@Json(name = "toNetwork")
val toNetwork: String,
@Json(name = "toDecimals")
val toDecimals: Int,
/** Provider-promised amount, received from the provider in the webhook */
@Json(name = "toAmount")
val toAmount: String?,
/** Actual amount delivered to the user, received from the provider in the webhook */
@Json(name = "toActualAmount")
val toActualAmount: String?,
// endregion
@Json(name = "paymentMethod")
val paymentMethod: String,
@Json(name = "countryCode")
val countryCode: String,
)

View file

@ -46,18 +46,36 @@ interface TangemPayApi {
@Path("order_id") orderId: String,
): ApiResponse<OrderResponse>
/**
* Find user orders, filtered by types and/or statuses. Source of truth for resolving active orders.
*
* Multiple values for the same query key are sent as repeated `order_types=A&order_types=B` params.
*/
@GET("v1/order")
suspend fun findOrders(
@Header("Authorization") authHeader: String,
@Query("order_types") orderTypes: List<String>?,
@Query("order_statuses") orderStatuses: List<String>?,
): ApiResponse<FindOrdersResponse>
@POST("v1/order")
suspend fun createOrder(
@Header("Authorization") authHeader: String,
@Body body: OrderRequest,
): ApiResponse<OrderResponse>
/** Customer offers — used to gate the issue-additional-card flow. */
@GET("v1/customer/offers")
suspend fun getCustomerOffers(@Header("Authorization") authHeader: String): ApiResponse<CustomerOffersResponse>
@GET("v1/customer/balance")
suspend fun getCardBalance(@Header("Authorization") authHeader: String): ApiResponse<CardBalanceResponse>
@POST("v1/customer/card/details")
/** Card-scoped reveal. `{card_id}` = selected card id. */
@POST("v1/customer/card/{card_id}/details")
suspend fun revealCardDetails(
@Header("Authorization") authHeader: String,
@Path("card_id") cardId: String,
@Body body: CardDetailsRequest,
): ApiResponse<CardDetailsResponse>
@ -97,6 +115,12 @@ interface TangemPayApi {
@Body body: ReissueCardRequest,
): ApiResponse<ReissueCardResponse>
@POST("v1/customer/card/close")
suspend fun closeCard(
@Header("Authorization") authHeader: String,
@Body body: CloseCardRequest,
): ApiResponse<CloseCardResponse>
@POST("v1/customer/card/withdraw/data")
suspend fun getWithdrawData(
@Header("Authorization") authHeader: String,

View file

@ -0,0 +1,9 @@
package com.tangem.datasource.api.pay.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CloseCardRequest(
@Json(name = "card_id") val cardId: String,
)

View file

@ -4,7 +4,10 @@ import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class OrderRequest(@Json(name = "data") val data: Data) {
data class OrderRequest(
@Json(name = "data") val data: Data,
@Json(name = "idempotency_key") val idempotencyKey: String,
) {
@JsonClass(generateAdapter = true)
data class Data(
@Json(name = "customer_wallet_address") val customerWalletAddress: String,

View file

@ -0,0 +1,15 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CloseCardResponse(
@Json(name = "result") val result: Result,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "order_id") val orderId: String,
@Json(name = "status") val status: OrderResponse.Result.Status,
)
}

View file

@ -19,6 +19,8 @@ data class CustomerMeResponse(
@Json(name = "deposit_address") val depositAddress: String?,
@Json(name = "card") val card: Card?,
@Json(name = "balance") val balance: BalanceResponse?,
@Json(name = "product_instances") val productInstances: List<ProductInstance>,
@Json(name = "cards") val cards: List<Card>,
)
@JsonClass(generateAdapter = true)
@ -99,6 +101,9 @@ data class CustomerMeResponse(
@JsonClass(generateAdapter = true)
data class Card(
// Present in the multi-card `cards[]` array to join a card to its product instance;
// absent in the legacy single-card `card` object, where the card joins the single product instance.
@Json(name = "card_id") val cardId: String?,
@Json(name = "token") val token: String,
@Json(name = "expiration_month") val expirationMonth: String,
@Json(name = "expiration_year") val expirationYear: String,

View file

@ -0,0 +1,35 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
/**
* Response from `GET /v1/customer/offers` list of offers available to the customer.
*
* Used to gate the issue-additional-card flow.
*/
@JsonClass(generateAdapter = true)
data class CustomerOffersResponse(
@Json(name = "result") val result: List<Offer>,
) {
@JsonClass(generateAdapter = true)
data class Offer(
@Json(name = "type") val type: String,
@Json(name = "fee") val fee: Fee,
@Json(name = "data") val data: Data,
)
@JsonClass(generateAdapter = true)
data class Data(
@Json(name = "specification_name") val specificationName: String,
@Json(name = "order_type") val orderType: String,
)
@JsonClass(generateAdapter = true)
data class Fee(
@Json(name = "amount") val amount: BigDecimal,
@Json(name = "currency") val currency: String,
)
}

View file

@ -0,0 +1,15 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Response from `GET /v1/order` (findOrders) array of orders matching the requested
* `order_types` / `order_statuses` filters.
*
* Each order shares the same shape as the single-order [OrderResponse.Result].
*/
@JsonClass(generateAdapter = true)
data class FindOrdersResponse(
@Json(name = "result") val result: List<OrderResponse.Result>,
)

View file

@ -113,4 +113,10 @@ internal object ApiConfigsModule {
fun provideSurveySparrowConfig(environmentConfig: EnvironmentConfig): ApiConfig {
return SurveySparrow(environmentConfig)
}
@Provides
@IntoSet
fun provideAuthConfig(): ApiConfig {
return Auth()
}
}

View file

@ -10,6 +10,7 @@ import com.tangem.datasource.api.common.adapter.*
import com.tangem.datasource.local.config.providers.models.ProviderModel
import com.tangem.datasource.local.network.entity.NetworkStatusDM
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM
import com.tangem.datasource.local.visa.entity.VirtualAccountStatusValueDM
import com.tangem.datasource.utils.SerializeNullsFactory
import com.tangem.domain.models.scan.serialization.*
import dagger.Module
@ -56,6 +57,18 @@ class MoshiModule {
.withSubtype(PaymentAccountStatusValueDM.DeactivatedAccount::class.java, "deactivated_account")
.withSubtype(PaymentAccountStatusValueDM.CardIssueFailed::class.java, "card_issue_failed"),
)
.add(
NamePolymorphicAdapterFactory.of(VirtualAccountStatusValueDM::class.java)
.withSubtype(VirtualAccountStatusValueDM.Empty::class.java, "empty")
.withSubtype(VirtualAccountStatusValueDM.NotCreated::class.java, "not_created")
.withSubtype(VirtualAccountStatusValueDM.UnderReview::class.java, "kyc_status")
.withSubtype(VirtualAccountStatusValueDM.Provisioning::class.java, "provisioning")
.withSubtype(
VirtualAccountStatusValueDM.CountryNotSupported::class.java,
"country_not_supported",
)
.withSubtype(VirtualAccountStatusValueDM.ActiveAccount::class.java, "active_account"),
)
.add(
PolymorphicJsonAdapterFactory.of(NFTCollection.Identifier::class.java, "bc")
.withSubtype(NFTCollection.Identifier.EVM::class.java, "evm")

View file

@ -1,6 +1,7 @@
package com.tangem.datasource.di
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.auth.AuthApi
import com.tangem.datasource.api.common.blockaid.BlockAidApi
import com.tangem.datasource.api.surveysparrow.SurveySparrowApi
import com.tangem.datasource.api.common.config.ApiConfig
@ -60,6 +61,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.Express,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@ -69,6 +71,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.StakeKit,
applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_60_SECONDS,
connectTimeoutSeconds = TIMEOUT_60_SECONDS,
@ -84,6 +87,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.P2PEthPool,
applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_90_SECONDS,
connectTimeoutSeconds = TIMEOUT_90_SECONDS,
@ -99,6 +103,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.Express,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@ -108,6 +113,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemTech,
applyTimeoutAnnotations = true,
sessionAuth = false,
)
}
@ -117,6 +123,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.YieldSupply,
applyTimeoutAnnotations = true,
sessionAuth = false,
)
}
@ -126,6 +133,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemTech,
applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_60_SECONDS,
connectTimeoutSeconds = TIMEOUT_60_SECONDS,
@ -141,6 +149,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemPay,
applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_60_SECONDS,
connectTimeoutSeconds = TIMEOUT_60_SECONDS,
@ -155,6 +164,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemPay,
applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_60_SECONDS,
connectTimeoutSeconds = TIMEOUT_60_SECONDS,
@ -169,6 +179,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemPayAuth,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@ -178,6 +189,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.BlockAid,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@ -187,6 +199,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.SurveySparrow,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@ -196,6 +209,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.MoonPay,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@ -205,6 +219,21 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.News,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@Provides
@Singleton
fun provideAuthApi(retrofitApiBuilder: RetrofitApiBuilder): AuthApi {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.Auth,
applyTimeoutAnnotations = false,
// Per-method annotations (`@RequiresDpopProof`, `@RequiresSessionAuth`) gate the hooks
// installed here. `/refresh` carries `@RequiresDpopProof` only, so the Authenticator
// skips it on 401 — no recursion into the refresher's mutex. Future session-protected
// endpoints (e.g. /wallet) will carry `@RequiresSessionAuth` and benefit from refresh-on-401.
sessionAuth = true,
)
}
@ -214,6 +243,7 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.GaslessTxService,
applyTimeoutAnnotations = false,
sessionAuth = false,
timeouts = Timeouts(
callTimeoutSeconds = TIMEOUT_60_SECONDS,
connectTimeoutSeconds = TIMEOUT_60_SECONDS,

View file

@ -3,8 +3,10 @@ package com.tangem.datasource.di
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.visa.DefaultTangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.DefaultTangemPayCloseCardStore
import com.tangem.datasource.local.visa.DefaultTangemPayReissueCardStore
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayCloseCardStore
import com.tangem.datasource.local.visa.TangemPayReissueCardStore
import dagger.Module
import dagger.Provides
@ -32,4 +34,12 @@ internal object TangemPayStoresModule {
prefs = prefs,
)
}
@Provides
@Singleton
fun provideTangemPayCloseCardStore(prefs: AppPreferencesStore): TangemPayCloseCardStore {
return DefaultTangemPayCloseCardStore(
prefs = prefs,
)
}
}

View file

@ -1,23 +1,15 @@
package com.tangem.datasource.di
import android.content.Context
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import androidx.room.Room
import com.tangem.datasource.local.txhistory.db.TxHistoryDatabase
import com.tangem.datasource.local.txhistory.store.CommonSyncState
import com.tangem.datasource.local.txhistory.store.CommonSyncStateKey
import com.tangem.datasource.local.txhistory.store.DefaultTxHistoryStore
import com.tangem.datasource.local.txhistory.store.TxHistoryStore
import com.tangem.datasource.utils.KotlinxDataStoreSerializer
import com.tangem.datasource.utils.KotlinxDataStoreSerializer.Companion.jsonBuilder
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.builtins.MapSerializer
import javax.inject.Singleton
@Module
@ -35,38 +27,15 @@ internal interface TxHistoryModule {
context = context,
klass = TxHistoryDatabase::class.java,
name = TX_HISTORY_DATABASE_NAME,
).build()
)
.fallbackToDestructiveMigration(true)
.build()
}
@Provides
@Singleton
fun provideTxHistoryStore(@ApplicationContext context: Context, appScope: AppCoroutineScope): TxHistoryStore {
val commonSerializer = KotlinxDataStoreSerializer(
defaultValue = emptyMap(),
serializer = MapSerializer(
CommonSyncStateKey.serializer(),
CommonSyncState.serializer(),
),
json = jsonBuilder {
allowStructuredMapKeys = true
},
)
fun provideExpressHistoryDao(database: TxHistoryDatabase): ExpressHistoryDao = database.expressHistoryDao()
val expressExchangeStore = DataStoreFactory.create(
serializer = commonSerializer,
produceFile = { context.dataStoreFile(fileName = "TxHistoryExpressExchangeStore") },
scope = appScope,
)
val expressOnrampStore = DataStoreFactory.create(
serializer = commonSerializer,
produceFile = { context.dataStoreFile(fileName = "TxHistoryExpressOnrampStore") },
scope = appScope,
)
return DefaultTxHistoryStore(
expressExchangeStore = expressExchangeStore,
expressOnrampStore = expressOnrampStore,
)
}
@Provides
fun provideSyncStateDao(database: TxHistoryDatabase): ExpressSyncStateDao = database.syncStateDao()
}
}

View file

@ -14,6 +14,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Named
import javax.inject.Singleton
@Module
@ -26,6 +27,11 @@ internal object ConfigModule {
return GeneratedEnvironmentConfigConverter.convert()
}
@Provides
@Singleton
@Named("authServiceKey")
fun provideAuthServiceKey(environmentConfig: EnvironmentConfig): String? = environmentConfig.authServiceKey
@Provides
@Singleton
fun provideTestnetTokensStorage(assetLoader: AssetLoader): TestnetTokensStorage {

View file

@ -5,6 +5,8 @@ import com.chuckerteam.chucker.api.ChuckerInterceptor
import com.squareup.moshi.Moshi
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.auth.qualifier.SessionAuthAuthenticator
import com.tangem.datasource.api.auth.qualifier.SessionAuthInterceptor
import com.tangem.datasource.api.common.SwitchEnvironmentInterceptor
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiConfigs
@ -16,11 +18,16 @@ import com.tangem.datasource.api.utils.ConnectTimeout
import com.tangem.datasource.api.utils.ReadTimeout
import com.tangem.datasource.api.utils.WriteTimeout
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.logs.SensitiveUrlMasker
import com.tangem.datasource.utils.NetworkLogsSaveInterceptor
import com.tangem.datasource.utils.WireMockRedirectInterceptor
import com.tangem.datasource.utils.addHeaders
import com.tangem.utils.JsonStringValuesExtractor
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.json.Json
import okhttp3.Authenticator
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import retrofit2.Invocation
@ -28,6 +35,8 @@ import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Provider
import javax.inject.Singleton
/**
@ -41,6 +50,7 @@ import javax.inject.Singleton
*
[REDACTED_AUTHOR]
*/
@Suppress("LongParameterList")
@Singleton
internal class RetrofitApiBuilder @Inject constructor(
private val apiConfigs: ApiConfigs,
@ -49,15 +59,32 @@ internal class RetrofitApiBuilder @Inject constructor(
private val analyticsErrorHandler: AnalyticsErrorHandler,
@ApplicationContext private val context: Context,
private val appLogsStore: AppLogsStore,
private val environmentConfig: EnvironmentConfig,
@SessionAuthInterceptor private val sessionAuthInterceptor: Provider<Interceptor>,
@SessionAuthAuthenticator private val sessionAuthenticator: Provider<Authenticator>,
@Named("isBackendAuthenticationEnabled") private val isBackendAuthEnabled: Provider<Boolean>,
) {
private val configsBaseUrls: Map<ApiConfig.ID, Set<String>> = getConfigsBaseUrls()
private val sensitiveUrlMasker: SensitiveUrlMasker by lazy {
val json = Json.encodeToJsonElement(EnvironmentConfig.serializer(), environmentConfig)
// Drop URL-shaped values (e.g. public endpoint URLs from config); they are not secrets
// and would obscure unrelated requests in logs.
val values = JsonStringValuesExtractor.extract(json)
.filter { it.isNotBlank() && !it.startsWith("http", ignoreCase = true) }
SensitiveUrlMasker(values)
}
/**
* Builds a Retrofit API instance for the specified API configuration ID
*
* @param apiConfigId the ID of the API configuration to use
* @param applyTimeoutAnnotations whether to apply timeout annotations to the requests. See [ReadTimeout], etc.
* @param sessionAuth when `true`, installs the DPoP `Interceptor` and 401/403
* `Authenticator` from `libs:auth`. Per-method annotations
* (`@RequiresDpopProof`, `@RequiresSessionRefresh`,
* `@RequiresSessionAuth`) gate which methods opt into each hook
* @param timeouts optional timeouts for the requests
* @param logsSaving whether to enable logs saving
*
@ -66,6 +93,7 @@ internal class RetrofitApiBuilder @Inject constructor(
inline fun <reified T> build(
apiConfigId: ApiConfig.ID,
applyTimeoutAnnotations: Boolean,
sessionAuth: Boolean,
timeouts: Timeouts? = null,
logsSaving: Boolean = true,
): T {
@ -79,6 +107,7 @@ internal class RetrofitApiBuilder @Inject constructor(
OkHttpClient.Builder()
.applyApiConfig(apiConfigId = apiConfigId, environmentConfig = environmentConfig)
.applyWireMockRedirect()
.applySessionAuth(sessionAuth)
.let {
if (applyTimeoutAnnotations) it.applyTimeoutAnnotations() else it
}
@ -93,6 +122,19 @@ internal class RetrofitApiBuilder @Inject constructor(
.create(T::class.java)
}
@PublishedApi
internal fun OkHttpClient.Builder.applySessionAuth(condition: Boolean): OkHttpClient.Builder {
// Belt-and-suspenders: callers opt in via the `sessionAuth` flag, but if the backend-auth
// feature toggle is OFF we skip installing the hooks entirely (avoids wiring up DPoP
// header generation and 401 retry logic on builds where auth isn't live yet).
if (condition && isBackendAuthEnabled.get()) {
addInterceptor(sessionAuthInterceptor.get())
authenticator(sessionAuthenticator.get())
}
return this
}
data class Timeouts(
val callTimeoutSeconds: Long? = null,
val connectTimeoutSeconds: Long? = null,
@ -179,7 +221,7 @@ internal class RetrofitApiBuilder @Inject constructor(
private fun OkHttpClient.Builder.applyLogsSaving(): OkHttpClient.Builder {
return addInterceptor(
interceptor = NetworkLogsSaveInterceptor(appLogsStore),
interceptor = NetworkLogsSaveInterceptor(appLogsStore, sensitiveUrlMasker),
)
}

View file

@ -13,4 +13,6 @@ data class UsedCardInfo(
val isActivationStarted: Boolean = false,
@Json(name = "isActivationFinished")
val isActivationFinished: Boolean = false,
@Json(name = "hasBackupError")
val hasBackupError: Boolean = false,
)

View file

@ -4,7 +4,10 @@ import com.tangem.blockchain.common.BlockchainSdkConfig
import com.tangem.datasource.local.config.environment.models.ExpressModel
import com.tangem.datasource.local.config.environment.models.P2PKeys
import com.tangem.datasource.local.config.environment.models.SurveySparrowSwapRatingConfig
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
@Serializable
data class EnvironmentConfig(
val moonPayApiKey: String = "",
val moonPayApiSecretKey: String = "",
@ -32,5 +35,7 @@ data class EnvironmentConfig(
val gaslessTxApiKey: String? = null,
val customerIoCdpApiKey: String? = null,
val surveySparrowToken: String? = null,
@Transient
val surveySparrowSwapRating: SurveySparrowSwapRatingConfig? = null,
val authServiceKey: String? = null,
)

View file

@ -56,6 +56,7 @@ internal object GeneratedEnvironmentConfigConverter {
customerIoCdpApiKey = GeneratedEnvironmentConfig.CustomerIO.androidApiKey,
surveySparrowToken = GeneratedEnvironmentConfig.SurveySparrow.apiKey,
surveySparrowSwapRating = createSurveySparrowSwapRating(),
authServiceKey = GeneratedEnvironmentConfig.authServiceKey,
)
}

View file

@ -1,7 +1,11 @@
package com.tangem.datasource.local.config.environment.models
import kotlinx.serialization.Serializable
@Serializable
data class ExpressModel(val apiKey: String, val signVerifierPublicKey: String)
@Serializable
data class P2PKeys(val mainnet: String, val hoodi: String)
data class SurveySparrowSwapRatingConfig(

View file

@ -0,0 +1,79 @@
package com.tangem.datasource.local.converter
import com.tangem.datasource.api.express.models.response.ExchangeItemResponse
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
/**
* Maps API history items into their persisted [androidx.room.Entity] representations.
*
* @param ownerAddress address the history was requested for. Stored as the query key.
*/
fun ExchangeItemResponse.toEntity(ownerAddress: String): ExpressExchangeEntity {
return ExpressExchangeEntity(
txId = txId,
ownerAddress = ownerAddress,
providerId = providerId,
fromAddress = fromAddress,
payinAddress = payinAddress,
payinExtraId = payinExtraId,
payoutAddress = payoutAddress,
refundAddress = refundAddress,
refundExtraId = refundExtraId,
rateType = rateType,
status = status,
externalTxId = externalTxId,
externalTxUrl = externalTxUrl,
payinHash = payinHash,
payoutHash = payoutHash,
refundNetwork = refundNetwork,
refundContractAddress = refundContractAddress,
createdAt = createdAt,
updatedAt = updatedAt,
payTill = payTill,
averageDuration = averageDuration,
from = ExpressExchangeEntity.AssetEmbedded(
contractAddress = fromContractAddress,
network = fromNetwork,
decimals = fromDecimals,
amount = fromAmount,
actualAmount = null,
),
to = ExpressExchangeEntity.AssetEmbedded(
contractAddress = toContractAddress,
network = toNetwork,
decimals = toDecimals,
amount = toAmount,
actualAmount = toActualAmount,
),
)
}
fun OnrampItemResponse.toEntity(ownerAddress: String): ExpressOnrampEntity {
return ExpressOnrampEntity(
txId = txId,
ownerAddress = ownerAddress,
providerId = providerId,
payoutAddress = payoutAddress,
status = status,
failReason = failReason,
externalTxId = externalTxId,
externalTxUrl = externalTxUrl,
payoutHash = payoutHash,
createdAt = createdAt,
updatedAt = updatedAt,
fromCurrencyCode = fromCurrencyCode,
fromAmount = fromAmount,
fromPrecision = fromPrecision,
to = ExpressOnrampEntity.AssetEmbedded(
contractAddress = toContractAddress,
network = toNetwork,
decimals = toDecimals,
amount = toAmount,
actualAmount = toActualAmount,
),
paymentMethod = paymentMethod,
countryCode = countryCode,
)
}

View file

@ -0,0 +1,26 @@
package com.tangem.datasource.local.converter
import com.tangem.datasource.api.express.models.response.ExchangeProvider
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity
/**
* Maps an [ExchangeProvider] API response into its persisted [ExpressProviderEntity] representation.
*
* `type` is stored as the [com.tangem.datasource.api.express.models.response.ExchangeProviderType] name
* (DEX / CEX / DEX_BRIDGE / ONRAMP); `slippage` as a plain decimal string.
*/
fun ExchangeProvider.toEntity(): ExpressProviderEntity {
return ExpressProviderEntity(
id = id,
name = name,
type = type.name,
imageLarge = imageLargeUrl,
imageSmall = imageSmallUrl,
termsOfUse = termsOfUse,
privacyPolicy = privacyPolicy,
isRecommended = isRecommended,
slippage = slippage?.toPlainString(),
isExchangeOnlyWithinSingleAddress = isExchangeOnlyWithinSingleAddress,
isExtraIdSupported = isExtraIdSupported,
)
}

View file

@ -0,0 +1,24 @@
package com.tangem.datasource.local.logs
class SensitiveUrlMasker(sensitiveValues: Collection<String>) {
// Sorted by descending length so a value that is a prefix of another (e.g. "my-node" vs
// "my-node-prod") cannot mask the shorter one first and leave the suffix in the log.
private val sensitiveValues: List<String> = sensitiveValues
.distinct()
.sortedByDescending(String::length)
fun mask(url: String): String {
var result = url
for (value in sensitiveValues) {
if (result.contains(value, ignoreCase = true)) {
result = result.replace(value, MASKED_VALUE, ignoreCase = true)
}
}
return result
}
companion object {
const val MASKED_VALUE = "******"
}
}

View file

@ -103,6 +103,8 @@ object PreferencesKeys {
val IS_GOOGLE_PAY_AVAILABLE_KEY by lazy { booleanPreferencesKey(name = "isGooglePayAvailable") }
val IS_DEVICE_REGISTERED_KEY by lazy { booleanPreferencesKey(name = "isDeviceRegistered") }
val WAS_LOG_FILE_CLEARED by lazy { booleanPreferencesKey(name = "wasLogFileCleared") }
val SEED_FIRST_NOTIFICATION_SHOW_TIME by lazy { longPreferencesKey("seedFirstNotificationTime") }

View file

@ -2,7 +2,9 @@ package com.tangem.datasource.local.txhistory.db
import androidx.room.Database
import androidx.room.RoomDatabase
import com.tangem.datasource.local.txhistory.db.entity.ExpressHistoryDao
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity
@ -13,9 +15,12 @@ import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEn
ExpressProviderEntity::class,
ExpressExchangeEntity::class,
ExpressOnrampEntity::class,
ExpressSyncStateEntity::class,
],
)
abstract class TxHistoryDatabase : RoomDatabase() {
abstract fun expressHistoryDao(): ExpressHistoryDao
abstract fun syncStateDao(): ExpressSyncStateDao
}

View file

@ -0,0 +1,97 @@
package com.tangem.datasource.local.txhistory.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.MapColumn
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface ExpressHistoryDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertProviders(items: List<ExpressProviderEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertExchanges(items: List<ExpressExchangeEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertOnramps(items: List<ExpressOnrampEntity>)
/**
* All persisted providers keyed by [ExpressProviderEntity.id]
*/
@Query("SELECT * FROM express_provider")
fun getProvidersById(): Flow<Map<@MapColumn(columnName = "id") String, ExpressProviderEntity>>
/**
* Outgoing swaps: the viewed currency is the swap's `from` side, so the row is stored under this
* address ([ExpressExchangeEntity.ownerAddress] == fromAddress). Join to on-chain by `payin_hash`.
*
* loading the whole table; [activeStatuses] keeps in-progress deals visible even outside the window.
*/
@Query(
"""
SELECT * FROM express_exchange
WHERE owner_address = :ownerAddress
AND from_network = :network
AND from_contract_address = :contract
AND (created_at >= :fromCreatedAtIso OR status IN (:activeStatuses))
ORDER BY created_at DESC
""",
)
fun observeOutgoingSwaps(
ownerAddress: String,
network: String,
contract: String,
fromCreatedAtIso: String,
activeStatuses: List<String>,
): Flow<List<ExpressExchangeEntity>>
/**
* Incoming swaps: the viewed currency is the swap's `to` side. Such a deal was initiated from a
* different coin, so the row is stored under that coin's `owner_address` hence this query is
* cross-owner, matched by the `to` asset. Join to on-chain by `payout_hash`.
*/
@Query(
"""
SELECT * FROM express_exchange
WHERE to_network = :network
AND to_contract_address = :contract
AND (created_at >= :fromCreatedAtIso OR status IN (:activeStatuses))
ORDER BY created_at DESC
""",
)
fun observeIncomingSwaps(
network: String,
contract: String,
fromCreatedAtIso: String,
activeStatuses: List<String>,
): Flow<List<ExpressExchangeEntity>>
/**
* Onramp is always incoming: [ExpressOnrampEntity.ownerAddress] == payoutAddress. Join by `payout_hash`.
*/
@Query(
"""
SELECT * FROM express_onramp
WHERE owner_address = :ownerAddress
AND to_network = :network
AND to_contract_address = :contract
AND (created_at >= :fromCreatedAtIso OR status IN (:activeStatuses))
ORDER BY created_at DESC
""",
)
fun observeIncomingOnramps(
ownerAddress: String,
network: String,
contract: String,
fromCreatedAtIso: String,
activeStatuses: List<String>,
): Flow<List<ExpressOnrampEntity>>
}

View file

@ -0,0 +1,47 @@
package com.tangem.datasource.local.txhistory.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface ExpressSyncStateDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(item: ExpressSyncStateEntity)
@Query(
"""
UPDATE express_sync_state
SET after_cursor = :afterCursor,
is_initial_completed = :isInitialCompleted
WHERE type = :type
AND address = :address
""",
)
suspend fun updateHistoryCursor(type: String, address: String, afterCursor: String?, isInitialCompleted: Boolean)
@Query(
"""
UPDATE express_sync_state
SET delta_cursor = :deltaCursor
WHERE type = :type
AND address = :address
""",
)
suspend fun updateDeltaCursor(type: String, address: String, deltaCursor: String)
@Query(
"""
SELECT *
FROM express_sync_state
WHERE type = :type
AND address = :address
LIMIT 1
""",
)
fun observe(type: String, address: String): Flow<ExpressSyncStateEntity?>
}

View file

@ -1,87 +0,0 @@
package com.tangem.datasource.local.txhistory.db.entity
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface ExpressHistoryDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertProviders(items: List<ExpressProviderEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertExchanges(items: List<ExpressExchangeEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertOnramps(items: List<ExpressOnrampEntity>)
@Query(
"""
SELECT *
FROM express_exchange
WHERE owner_address = :ownerAddress
ORDER BY updated_at DESC
""",
)
fun observeExchanges(ownerAddress: String): Flow<List<ExpressExchangeEntity>>
@Query(
"""
SELECT *
FROM express_onramp
WHERE owner_address = :ownerAddress
ORDER BY updated_at DESC
""",
)
fun observeOnramps(ownerAddress: String): Flow<List<ExpressOnrampEntity>>
@Query(
"""
SELECT *
FROM express_exchange
WHERE owner_address = :ownerAddress
AND payin_hash = :hash
LIMIT 1
""",
)
suspend fun findExchangeByPayinHash(ownerAddress: String, hash: String): ExpressExchangeEntity?
@Query(
"""
SELECT *
FROM express_exchange
WHERE owner_address = :ownerAddress
AND payout_hash = :hash
LIMIT 1
""",
)
suspend fun findExchangeByPayoutHash(ownerAddress: String, hash: String): ExpressExchangeEntity?
@Query(
"""
SELECT *
FROM express_exchange
WHERE owner_address = :ownerAddress
AND refund_hash = :hash
LIMIT 1
""",
)
suspend fun findExchangeByRefundHash(ownerAddress: String, hash: String): ExpressExchangeEntity?
@Query(
"""
SELECT *
FROM express_onramp
WHERE owner_address = :ownerAddress
AND payout_hash = :hash
LIMIT 1
""",
)
suspend fun findOnrampByPayoutHash(ownerAddress: String, hash: String): ExpressOnrampEntity?
}

View file

@ -2,22 +2,19 @@ package com.tangem.datasource.local.txhistory.db.entity.express
import androidx.room.*
@Suppress("BooleanPropertyNaming")
/**
* Persisted representation of a single exchange transaction.
*
* Mirrors [com.tangem.datasource.api.express.models.response.ExchangeItemResponse].
*/
@Entity(
tableName = "express_exchange",
foreignKeys = [
ForeignKey(
entity = ExpressProviderEntity::class,
parentColumns = ["id"],
childColumns = ["provider_id"],
onDelete = ForeignKey.RESTRICT,
),
],
indices = [
Index(value = ["owner_address", "from_network", "updated_at"]),
Index(value = ["owner_address", "payin_hash"]),
Index(value = ["owner_address", "payout_hash"]),
Index(value = ["owner_address", "refund_hash"]),
// Outgoing swaps lookup (observeOutgoingSwaps): owner + from-asset equality, created_at range/sort.
Index(value = ["owner_address", "from_network", "from_contract_address", "created_at"]),
// Incoming (cross-owner) swaps lookup (observeIncomingSwaps): to-asset equality, created_at range/sort.
// No owner filter here, so to_contract_address in the index is what keeps a popular to-network selective.
Index(value = ["to_network", "to_contract_address", "created_at"]),
],
)
data class ExpressExchangeEntity(
@ -26,6 +23,9 @@ data class ExpressExchangeEntity(
@ColumnInfo(name = "tx_id")
val txId: String,
/**
* Address used to query the history. For exchange it matches [fromAddress].
*/
@ColumnInfo(name = "owner_address")
val ownerAddress: String,
@ -33,48 +33,31 @@ data class ExpressExchangeEntity(
val providerId: String,
/**
* waiting
* confirming
* exchanging
* sending
* finished
* failed
* refunded
* expired
* Address from which the `from` assets were taken for the exchange. Optional because the very first
* app versions did not send it; for newer versions it can be considered effectively mandatory.
*/
@ColumnInfo(name = "status")
val status: String,
@ColumnInfo(name = "from_address")
val fromAddress: String?,
@Embedded(prefix = "from_")
val from: AssetEmbedded,
/** Address to which the source assets were transferred for the exchange */
@ColumnInfo(name = "payin_address")
val payinAddress: String,
@Embedded(prefix = "to_")
val to: AssetEmbedded,
/** Extra ID used for the pay-in transaction */
@ColumnInfo(name = "payin_extra_id")
val payinExtraId: String?,
/**
* true -> actual provider-confirmed amount
* false -> estimated amount
*/
@ColumnInfo(name = "to_is_actual", defaultValue = "0")
val toIsActual: Boolean,
/** Address that received the target assets */
@ColumnInfo(name = "payout_address")
val payoutAddress: String,
/**
* Match key for PAYIN leg
*/
@ColumnInfo(name = "payin_hash")
val payinHash: String?,
/** Refund destination address */
@ColumnInfo(name = "refund_address")
val refundAddress: String?,
/**
* Match key for PAYOUT leg
*/
@ColumnInfo(name = "payout_hash")
val payoutHash: String?,
@ColumnInfo(name = "external_tx_id")
val externalTxId: String?,
@ColumnInfo(name = "external_tx_url")
val externalTxUrl: String?,
/** Extra ID used for refunds */
@ColumnInfo(name = "refund_extra_id")
val refundExtraId: String?,
/**
* fixed / float
@ -82,49 +65,76 @@ data class ExpressExchangeEntity(
@ColumnInfo(name = "rate_type")
val rateType: String,
/**
* Raw backend status string, persisted as-is (kept unparsed so a new value never breaks anything).
* Typed view: [com.tangem.domain.express.models.ExpressExchangeStatus].
*/
@ColumnInfo(name = "status")
val status: String,
/** External transaction ID (CEX only) */
@ColumnInfo(name = "external_tx_id")
val externalTxId: String?,
/** URL to view the transaction details (CEX only) */
@ColumnInfo(name = "external_tx_url")
val externalTxUrl: String?,
/** Blockchain hash of the pay-in transaction */
@ColumnInfo(name = "payin_hash")
val payinHash: String?,
/** Blockchain hash of the payout transaction */
@ColumnInfo(name = "payout_hash")
val payoutHash: String?,
/** Network used for the refund transaction */
@ColumnInfo(name = "refund_network")
val refundNetwork: String?,
/** Refunded token contract address */
@ColumnInfo(name = "refund_contract_address")
val refundContractAddress: String?,
/** Transaction creation timestamp in ISO-8601 format */
@ColumnInfo(name = "created_at")
val createdAt: Long,
val createdAt: String,
/** Transaction last-update timestamp in ISO-8601 format */
@ColumnInfo(name = "updated_at")
val updatedAt: Long,
val updatedAt: String,
@Embedded(prefix = "refund_")
val refund: RefundEmbedded?,
/** Pay-in expiration timestamp in ISO-8601 format */
@ColumnInfo(name = "pay_till")
val payTill: String?,
/** Average provider exchange duration in seconds */
@ColumnInfo(name = "average_duration")
val averageDuration: Long?,
@Embedded(prefix = "from_")
val from: AssetEmbedded,
@Embedded(prefix = "to_")
val to: AssetEmbedded,
) {
data class AssetEmbedded(
@ColumnInfo(name = "contract_address")
val contractAddress: String,
@ColumnInfo(name = "network")
val network: String,
@ColumnInfo(name = "token_id")
val tokenId: String?,
@ColumnInfo(name = "raw_amount")
val rawAmount: String,
@ColumnInfo(name = "decimals")
val decimals: Int,
)
data class RefundEmbedded(
@ColumnInfo(name = "amount")
val amount: String,
@ColumnInfo(name = "network")
val network: String?,
@ColumnInfo(name = "token_id")
val tokenId: String?,
@ColumnInfo(name = "raw_amount")
val rawAmount: String?,
@ColumnInfo(name = "decimals")
val decimals: Int?,
/**
* Match key for REFUND leg
*/
@ColumnInfo(name = "hash")
val hash: String?,
/** Actual provider-confirmed amount. Present only for the [ExpressExchangeEntity.to] asset */
@ColumnInfo(name = "actual_amount")
val actualAmount: String?,
)
}

View file

@ -2,19 +2,16 @@ package com.tangem.datasource.local.txhistory.db.entity.express
import androidx.room.*
/**
* Persisted representation of a single onramp transaction.
*
* Mirrors [com.tangem.datasource.api.onramp.models.response.OnrampItemResponse].
*/
@Entity(
tableName = "express_onramp",
foreignKeys = [
ForeignKey(
entity = ExpressProviderEntity::class,
parentColumns = ["id"],
childColumns = ["provider_id"],
onDelete = ForeignKey.RESTRICT,
),
],
indices = [
Index(value = ["owner_address", "to_network", "updated_at"]),
Index(value = ["owner_address", "payout_hash"]),
// Incoming onramp lookup (observeIncomingOnramps): owner + to-asset equality, created_at range/sort.
Index(value = ["owner_address", "to_network", "to_contract_address", "created_at"]),
],
)
data class ExpressOnrampEntity(
@ -23,100 +20,89 @@ data class ExpressOnrampEntity(
@ColumnInfo(name = "tx_id")
val txId: String,
/**
* Address used to query the history. For onramp it matches [payoutAddress].
*/
@ColumnInfo(name = "owner_address")
val ownerAddress: String,
@ColumnInfo(name = "provider_id")
val providerId: String,
/**
/** Address that received the target assets */
@ColumnInfo(name = "payout_address")
val payoutAddress: String,
* waiting-for-payment
* payment-processing
* paused
* verifying
* sending
* finished
* failed
* expired
* refunded
/**
* Raw backend status string, persisted as-is (kept unparsed so a new value never breaks anything).
* Typed view: [com.tangem.domain.express.models.ExpressOnrampStatus].
*/
@ColumnInfo(name = "status")
val status: String,
/**
* ISO-4217
*/
@ColumnInfo(name = "from_currency_code")
val fromCurrencyCode: String,
/**
* Decimal string
*/
@ColumnInfo(name = "from_amount")
val fromAmount: String,
@ColumnInfo(name = "to_network")
val toNetwork: String,
@ColumnInfo(name = "to_token_id")
val toTokenId: String?,
/**
* Estimated amount at creation moment
*/
@ColumnInfo(name = "to_expected_raw_amount")
val toExpectedRawAmount: String,
/**
* Actual provider-confirmed amount
*/
@ColumnInfo(name = "to_actual_raw_amount")
val toActualRawAmount: String?,
@ColumnInfo(name = "to_decimals")
val toDecimals: Int,
/**
* Match key with gateway_tx.hash
*/
@ColumnInfo(name = "payout_hash")
val payoutHash: String?,
@ColumnInfo(name = "external_tx_id")
val externalTxId: String?,
@ColumnInfo(name = "external_tx_url")
val externalTxUrl: String?,
/**
* fixed / float
*/
@ColumnInfo(name = "rate_type")
val rateType: String,
/** Failure reason reported by the provider */
@ColumnInfo(name = "fail_reason")
val failReason: String?,
/** External transaction ID reported by the provider in the webhook */
@ColumnInfo(name = "external_tx_id")
val externalTxId: String?,
/** URL to view the transaction details on the provider side (not provided by all providers) */
@ColumnInfo(name = "external_tx_url")
val externalTxUrl: String?,
/** Blockchain hash of the payout transaction */
@ColumnInfo(name = "payout_hash")
val payoutHash: String?,
/** Transaction creation timestamp in ISO-8601 format */
@ColumnInfo(name = "created_at")
val createdAt: Long,
val createdAt: String,
/** Transaction last-update timestamp in ISO-8601 format */
@ColumnInfo(name = "updated_at")
val updatedAt: Long,
val updatedAt: String,
@Embedded(prefix = "refund_")
val refund: RefundEmbedded?,
/** Fiat currency code of the source funds */
@ColumnInfo(name = "from_currency_code")
val fromCurrencyCode: String,
/** Fiat amount of the source funds */
@ColumnInfo(name = "from_amount")
val fromAmount: String,
/** Number of decimal places of the source fiat currency */
@ColumnInfo(name = "from_precision")
val fromPrecision: Int,
@Embedded(prefix = "to_")
val to: AssetEmbedded,
@ColumnInfo(name = "payment_method")
val paymentMethod: String,
@ColumnInfo(name = "country_code")
val countryCode: String,
) {
data class RefundEmbedded(
data class AssetEmbedded(
/**
* ISO-4217
*/
@ColumnInfo(name = "currency_code")
val currencyCode: String?,
@ColumnInfo(name = "contract_address")
val contractAddress: String,
@ColumnInfo(name = "network")
val network: String,
@ColumnInfo(name = "decimals")
val decimals: Int,
/** Provider-promised amount. Present only if the provider reported it */
@ColumnInfo(name = "amount")
val amount: String?,
/** Actual provider-confirmed amount delivered to the user */
@ColumnInfo(name = "actual_amount")
val actualAmount: String?,
)
}

View file

@ -4,9 +4,13 @@ import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(
tableName = "express_provider",
)
/**
* Persisted representation of an express provider.
*
* Mirrors [com.tangem.datasource.api.express.models.response.ExchangeProvider]. Mapped into
* [com.tangem.domain.express.models.ExpressProvider] when read back.
*/
@Entity(tableName = "express_provider")
data class ExpressProviderEntity(
@PrimaryKey
@ -16,9 +20,34 @@ data class ExpressProviderEntity(
@ColumnInfo(name = "name")
val name: String,
@ColumnInfo(name = "icon_url")
val iconUrl: String,
/** Raw provider type (`dex` / `cex` / `dex-bridge` / `onramp`). Typed view: ExpressProviderType. */
@ColumnInfo(name = "type")
val type: String,
@ColumnInfo(name = "provider_url")
val providerUrl: String,
/** Large logo image URL. */
@ColumnInfo(name = "image_large")
val imageLarge: String,
/** Small logo image URL. */
@ColumnInfo(name = "image_small")
val imageSmall: String,
@ColumnInfo(name = "terms_of_use")
val termsOfUse: String?,
@ColumnInfo(name = "privacy_policy")
val privacyPolicy: String?,
@ColumnInfo(name = "is_recommended")
val isRecommended: Boolean,
/** Raw decimal string (BigDecimal) or `null`. */
@ColumnInfo(name = "slippage")
val slippage: String?,
@ColumnInfo(name = "is_exchange_only_within_single_address")
val isExchangeOnlyWithinSingleAddress: Boolean,
@ColumnInfo(name = "is_extra_id_supported")
val isExtraIdSupported: Boolean,
)

View file

@ -0,0 +1,39 @@
package com.tangem.datasource.local.txhistory.db.entity.express
import androidx.room.ColumnInfo
import androidx.room.Entity
/**
* Persisted sync state of the express tx history.
*
* Stored inside [com.tangem.datasource.local.txhistory.db.TxHistoryDatabase] on purpose: if the history tables are
* dropped (e.g. destructive migration), the sync state is wiped together with them and the history is re-synced
* from scratch.
*/
@Entity(
tableName = "express_sync_state",
primaryKeys = ["type", "address"],
)
data class ExpressSyncStateEntity(
@ColumnInfo(name = "type")
val type: String,
@ColumnInfo(name = "address")
val address: String,
@ColumnInfo(name = "is_initial_completed")
val isInitialCompleted: Boolean,
@ColumnInfo(name = "after_cursor")
val afterCursor: String?,
@ColumnInfo(name = "delta_cursor")
val deltaCursor: String?,
) {
enum class Type {
EXCHANGE,
ONRAMP,
}
}

View file

@ -1,38 +0,0 @@
package com.tangem.datasource.local.txhistory.store
import androidx.datastore.core.DataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
internal class DefaultTxHistoryStore(
private val expressExchangeStore: DataStore<Map<CommonSyncStateKey, CommonSyncState>>,
private val expressOnrampStore: DataStore<Map<CommonSyncStateKey, CommonSyncState>>,
) : TxHistoryStore {
override fun expressExchangeSyncState(key: CommonSyncStateKey): Flow<CommonSyncState> {
return expressExchangeStore.data.map { map -> map.getOrDefault(key) }
}
override fun expressOnrampSyncState(key: CommonSyncStateKey): Flow<CommonSyncState> {
return expressOnrampStore.data.map { map -> map.getOrDefault(key) }
}
override suspend fun updateExpressExchangeSyncState(
key: CommonSyncStateKey,
value: CommonSyncState,
): CommonSyncState {
return expressExchangeStore.updateData { map -> map.plus(key to value) }
.getOrDefault(key)
}
override suspend fun updateExpressOnrampSyncState(
key: CommonSyncStateKey,
value: CommonSyncState,
): CommonSyncState {
return expressOnrampStore.updateData { map -> map.plus(key to value) }
.getOrDefault(key)
}
private fun Map<CommonSyncStateKey, CommonSyncState>.getOrDefault(key: CommonSyncStateKey): CommonSyncState =
this.getOrDefault(key, CommonSyncState.default(key))
}

View file

@ -1,27 +0,0 @@
package com.tangem.datasource.local.txhistory.store
import com.tangem.domain.models.account.AccountId
import kotlinx.serialization.Serializable
@Serializable
data class CommonSyncStateKey(
val accountId: AccountId,
val address: String,
)
@Serializable
data class CommonSyncState(
val accountId: AccountId,
val address: String,
val isInitialCompleted: Boolean,
val cursor: String?,
) {
companion object {
fun default(key: CommonSyncStateKey) = CommonSyncState(
accountId = key.accountId,
address = key.address,
isInitialCompleted = false,
cursor = null,
)
}
}

View file

@ -1,12 +0,0 @@
package com.tangem.datasource.local.txhistory.store
import kotlinx.coroutines.flow.Flow
interface TxHistoryStore {
fun expressExchangeSyncState(key: CommonSyncStateKey): Flow<CommonSyncState>
fun expressOnrampSyncState(key: CommonSyncStateKey): Flow<CommonSyncState>
suspend fun updateExpressExchangeSyncState(key: CommonSyncStateKey, value: CommonSyncState): CommonSyncState
suspend fun updateExpressOnrampSyncState(key: CommonSyncStateKey, value: CommonSyncState): CommonSyncState
}

View file

@ -1,7 +1,7 @@
package com.tangem.datasource.local.visa
import com.tangem.datasource.local.datastore.core.StringKeyDataStore
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import kotlinx.coroutines.flow.Flow
internal class DefaultTangemPayCardFrozenStateStore(

View file

@ -0,0 +1,29 @@
package com.tangem.datasource.local.visa
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.datasource.local.preferences.utils.store
internal class DefaultTangemPayCloseCardStore(
private val prefs: AppPreferencesStore,
) : TangemPayCloseCardStore {
override suspend fun setCloseOrderId(cardId: String, orderId: String?) {
if (orderId == null) {
prefs.edit { it.remove(getCloseKey(cardId)) }
} else {
prefs.store(
key = getCloseKey(cardId),
value = orderId,
)
}
}
override suspend fun getOrderId(cardId: String): String? {
return prefs.getSyncOrNull(key = getCloseKey(cardId))
}
private fun getCloseKey(cardId: String) = stringPreferencesKey("tangem_pay_close_card_$cardId")
}

View file

@ -1,6 +1,6 @@
package com.tangem.datasource.local.visa
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import kotlinx.coroutines.flow.Flow
interface TangemPayCardFrozenStateStore {

View file

@ -0,0 +1,8 @@
package com.tangem.datasource.local.visa
interface TangemPayCloseCardStore {
suspend fun setCloseOrderId(cardId: String, orderId: String?)
suspend fun getOrderId(cardId: String): String?
}

View file

@ -40,6 +40,7 @@ sealed interface PaymentAccountStatusValueDM {
@NameLabel("active_account")
data class ActiveAccount(
@Json(name = "active_account") val marker: Boolean = true,
@Json(name = "customer_id") val customerId: String,
@Json(name = "currency_code") val currencyCode: String,
@Json(name = "deposit_address") val depositAddress: String?,
@ -59,9 +60,11 @@ sealed interface PaymentAccountStatusValueDM {
@NameLabel("deactivated_account")
data class DeactivatedAccount(
@Json(name = "deactivated_account") val marker: Boolean = true,
@Json(name = "customer_id") val customerId: String,
@Json(name = "fiat_rate") val fiatRate: BigDecimal?,
@Json(name = "fiat_balance") val fiatBalance: FiatBalanceDM,
@Json(name = "crypto_balance") val cryptoBalance: CryptoBalanceDM,
@Json(name = "available_for_withdrawal") val availableForWithdrawal: BigDecimal,
) : PaymentAccountStatusValueDM
@JsonClass(generateAdapter = true)
@ -82,12 +85,14 @@ sealed interface PaymentAccountStatusValueDM {
@JsonClass(generateAdapter = true)
data class TangemPayCard(
@Json(name = "id") val id: String,
@Json(name = "product_instance_id") val productInstanceId: String,
@Json(name = "card_status") val cardStatus: String,
@Json(name = "has_pin_code") val hasPinCode: Boolean,
@Json(name = "display_name") val displayName: String?,
@Json(name = "actual_daily_limit") val actualDailyLimit: SerializedBigDecimal?,
@Json(name = "admin_daily_limit") val adminDailyLimit: SerializedBigDecimal?,
@Json(name = "is_frozen") val isFrozen: Boolean,
@Json(name = "frozen_state") val frozenState: String,
@Json(name = "last_digits") val lastDigits: String,
@Json(name = "is_reissuing") val isReissuing: Boolean,
@Json(name = "state") val state: String,
)
}

View file

@ -0,0 +1,71 @@
@file:Suppress("BooleanPropertyNaming")
package com.tangem.datasource.local.visa.entity
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.domain.models.kyc.KycStatus
import dev.onenowy.moshipolymorphicadapter.PolymorphicAdapterType
import dev.onenowy.moshipolymorphicadapter.annotations.NameLabel
import java.math.BigDecimal
/**
* Virtual account status for storage in the local cache.
*
* @see [com.tangem.domain.models.account.AccountStatus.Virtual]
*/
@JsonClass(generateAdapter = true, generator = PolymorphicAdapterType.NAME_POLYMORPHIC_ADAPTER)
sealed interface VirtualAccountStatusValueDM {
@NameLabel("empty")
data class Empty(
@Json(name = "empty") val marker: Boolean = true,
) : VirtualAccountStatusValueDM
@NameLabel("not_created")
data class NotCreated(
@Json(name = "not_created") val marker: Boolean = true,
) : VirtualAccountStatusValueDM
@NameLabel("kyc_status")
data class UnderReview(
@Json(name = "kyc_status") val kycStatus: KycStatus,
@Json(name = "customer_id") val customerId: String,
) : VirtualAccountStatusValueDM
@NameLabel("provisioning")
data class Provisioning(
@Json(name = "provisioning") val marker: Boolean = true,
) : VirtualAccountStatusValueDM
@NameLabel("country_not_supported")
data class CountryNotSupported(
@Json(name = "country_not_supported") val marker: Boolean = true,
) : VirtualAccountStatusValueDM
@NameLabel("active_account")
data class ActiveAccount(
@Json(name = "active_account") val marker: Boolean = true,
@Json(name = "customer_id") val customerId: String,
@Json(name = "currency_code") val currencyCode: String,
@Json(name = "deposit_address") val depositAddress: String?,
@Json(name = "fiat_balance") val fiatBalance: FiatBalanceDM,
@Json(name = "crypto_balance") val cryptoBalance: CryptoBalanceDM,
@Json(name = "fiat_rate") val fiatRate: BigDecimal?,
@Json(name = "available_for_withdrawal") val availableForWithdrawal: BigDecimal,
) : VirtualAccountStatusValueDM
@JsonClass(generateAdapter = true)
data class FiatBalanceDM(
@Json(name = "available_balance") val availableBalance: BigDecimal,
@Json(name = "currency") val currency: String,
)
@JsonClass(generateAdapter = true)
data class CryptoBalanceDM(
@Json(name = "id") val id: String,
@Json(name = "chain_id") val chainId: Long,
@Json(name = "deposit_address") val depositAddress: String,
@Json(name = "token_contract_address") val tokenContractAddress: String,
@Json(name = "balance") val balance: BigDecimal,
)
}

View file

@ -1,7 +1,9 @@
package com.tangem.datasource.utils
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.logs.SensitiveUrlMasker
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
@ -22,11 +24,15 @@ private const val JSON_INDENT_SPACES = 4
* Interceptor for save network requests and responses logs
*
* @property appLogsStore app logs store
* @property sensitiveUrlMasker masker for sensitive data in URLs
* @property shouldCheckResponseBodySize whether to skip logging large response bodies
*
[REDACTED_AUTHOR]
*/
class NetworkLogsSaveInterceptor(
private val appLogsStore: AppLogsStore,
private val sensitiveUrlMasker: SensitiveUrlMasker? = null,
private val shouldCheckResponseBodySize: Boolean = false,
) : Interceptor {
@Throws(IOException::class)
@ -65,7 +71,7 @@ class NetworkLogsSaveInterceptor(
val connection = chain.connection()
val connectionProtocol = if (connection != null) " ${connection.protocol()}" else ""
saveLogMessage("--> ${request.method} ${request.url}$connectionProtocol\n")
saveLogMessage("--> ${request.method} ${request.url.maskSensitiveInfo()}$connectionProtocol\n")
}
private fun logRequestMessage(chain: Interceptor.Chain, request: Request) {
@ -73,7 +79,7 @@ class NetworkLogsSaveInterceptor(
val connectionProtocol = if (connection != null) " ${connection.protocol()}" else ""
saveLogMessage(
"--> ${request.method} ${request.url}$connectionProtocol\n",
"--> ${request.method} ${request.url.maskSensitiveInfo()}$connectionProtocol\n",
createRequestEndMessage(request),
)
}
@ -110,7 +116,7 @@ class NetworkLogsSaveInterceptor(
val tookMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs)
saveLogMessage(
"<-- ${response.code}",
" ${response.request.url} (${tookMs}ms)\n",
" ${response.request.url.maskSensitiveInfo()} (${tookMs}ms)\n",
)
}
@ -123,39 +129,45 @@ class NetworkLogsSaveInterceptor(
"<-- END HTTP"
} else if (bodyHasUnknownEncoding(response.headers)) {
"<-- END HTTP (encoded body omitted)"
} else if (shouldCheckResponseBodySize && contentLength > WRITE_LOG_THRESHOLD_BYTES_SIZE) {
"Response size too large: $contentLength bytes \n<-- END HTTP"
} else {
val source = responseBody.source()
source.request(Long.MAX_VALUE)
var buffer = source.buffer
var gzippedLength: Long? = null
if ("gzip".equals(responseHeaders["Content-Encoding"], ignoreCase = true)) {
gzippedLength = buffer.size
GzipSource(buffer.clone()).use { gzippedResponseBody ->
buffer = Buffer()
buffer.writeAll(gzippedResponseBody)
}
}
val contentType = responseBody.contentType()
val charset: Charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
if (!buffer.isProbablyUtf8()) {
"<-- END HTTP (binary ${buffer.size}-byte body omitted)"
if (shouldCheckResponseBodySize && buffer.size > WRITE_LOG_THRESHOLD_BYTES_SIZE) {
"Response size too large: ${buffer.size} bytes \n<-- END HTTP"
} else {
val json = if (contentLength != 0L) {
buffer.clone().readString(charset).beautifyJson()
} else {
""
var gzippedLength: Long? = null
if ("gzip".equals(responseHeaders["Content-Encoding"], ignoreCase = true)) {
gzippedLength = buffer.size
GzipSource(buffer.clone()).use { gzippedResponseBody ->
buffer = Buffer()
buffer.writeAll(gzippedResponseBody)
}
}
val end = if (gzippedLength != null) {
"<-- END HTTP (${buffer.size}-byte, $gzippedLength-gzipped-byte body)"
} else {
"<-- END HTTP (${buffer.size}-byte body)"
}
val contentType = responseBody.contentType()
val charset: Charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
"$json\n$end"
if (!buffer.isProbablyUtf8()) {
"<-- END HTTP (binary ${buffer.size}-byte body omitted)"
} else {
val json = if (contentLength != 0L) {
buffer.clone().readString(charset).beautifyJson()
} else {
""
}
val end = if (gzippedLength != null) {
"<-- END HTTP (${buffer.size}-byte, $gzippedLength-gzipped-byte body)"
} else {
"<-- END HTTP (${buffer.size}-byte body)"
}
"$json\n$end"
}
}
}
@ -166,12 +178,16 @@ class NetworkLogsSaveInterceptor(
saveLogMessage(
"<-- ${response.code}",
spaceBeforeResponseMessage,
response.message,
" ${response.request.url} (${tookMs}ms)\n",
" ${response.request.url.maskSensitiveInfo()} (${tookMs}ms)\n",
message,
)
}
private fun HttpUrl.maskSensitiveInfo(): String {
val url = toString()
return sensitiveUrlMasker?.mask(url) ?: url
}
private fun bodyHasUnknownEncoding(headers: Headers): Boolean {
val contentEncoding = headers["Content-Encoding"] ?: return false
return !contentEncoding.equals("identity", ignoreCase = true) &&
@ -231,6 +247,9 @@ class NetworkLogsSaveInterceptor(
}
private companion object {
const val WRITE_LOG_THRESHOLD_BYTES_SIZE = 2_048_000L
/**
* List of URLs (host + path) for which logging is restricted
*/

View file

@ -14,21 +14,40 @@ class WireMockRedirectInterceptor : Interceptor {
val request = chain.request()
val url = request.url.toString()
val host = request.url.host
val sanitizedOverride = override.trimEnd('/')
if (url.contains(WIREMOCK_REMOTE_URL)) {
val newUrl = url.replace(WIREMOCK_REMOTE_URL, override.trimEnd('/'))
if (host == WIREMOCK_REMOTE_HOST) {
val newUrl = url.replace(WIREMOCK_REMOTE_URL, sanitizedOverride)
TangemLogger.d("WireMockRedirect: $url -> $newUrl")
val newRequest = request.newBuilder()
.url(newUrl)
.build()
return chain.proceed(newRequest)
return chain.proceed(request.newBuilder().url(newUrl).build())
}
if (host in REDIRECTABLE_THIRD_PARTY_HOSTS) {
val newUrl = url.replace("https://$host", "$sanitizedOverride/$host")
TangemLogger.d("WireMockRedirect (3p): $url -> $newUrl")
return chain.proceed(request.newBuilder().url(newUrl).build())
}
return chain.proceed(request)
}
companion object {
private const val WIREMOCK_REMOTE_URL = "[REDACTED_ENV_URL]"
private const val WIREMOCK_REMOTE_HOST = "wiremock.tests-d.com"
private const val WIREMOCK_REMOTE_URL = "https://$WIREMOCK_REMOTE_HOST"
/**
* Upstream hosts that have no other override knob and should be funnelled into WireMock
* when [overriddenBaseUrl] is set. Each matched URL becomes `<override>/<host>/<original-path>`,
* so mock mappings should live under that host-prefixed path in tangem-api-mocks. Matching
* is done against the request's parsed host (exact equality) substring matching would
* incorrectly redirect look-alikes such as `deep-index.moralis.io.evil.example`.
*/
private val REDIRECTABLE_THIRD_PARTY_HOSTS = setOf(
"deep-index.moralis.io",
"solana-gateway.moralis.io",
"api.etherscan.io",
)
/**
* Override base URL for WireMock requests.

View file

@ -88,6 +88,7 @@ class ApiConfigTest {
appInfoProvider = mockk(),
)
ApiConfig.ID.SurveySparrow -> SurveySparrow(environmentConfig = environmentConfig)
ApiConfig.ID.Auth -> Auth()
}
}
}

View file

@ -130,6 +130,7 @@ internal class ProdApiConfigsManagerTest {
appInfoProvider = appInfoProvider,
)
ApiConfig.ID.SurveySparrow -> SurveySparrow(environmentConfig = environmentConfig)
ApiConfig.ID.Auth -> Auth()
}
}
}
@ -148,9 +149,35 @@ internal class ProdApiConfigsManagerTest {
ApiConfig.ID.News -> createNewsModel()
ApiConfig.ID.GaslessTxService -> createGaslessTxServiceModel()
ApiConfig.ID.SurveySparrow -> createSurveySparrowModel()
ApiConfig.ID.Auth -> createAuthModel()
}
}
private fun createAuthModel(): TestModel {
val environment = when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE,
DEBUG_BUILD_TYPE,
INTERNAL_BUILD_TYPE,
-> ApiEnvironment.DEV
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
return TestModel(
id = ApiConfig.ID.Auth,
expected = ApiEnvironmentConfig(
environment = environment,
baseUrl = when (environment) {
ApiEnvironment.PROD -> "https://authentication.tangem.org/"
else -> "[REDACTED_ENV_URL]"
},
headers = emptyMap(),
),
)
}
private fun createExpressModel(): TestModel {
val environment = when (BuildConfig.BUILD_TYPE) {
DEBUG_BUILD_TYPE,
@ -294,6 +321,7 @@ internal class ProdApiConfigsManagerTest {
private fun createGaslessTxServiceModel(): TestModel {
val (environment, baseUrl) = when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE,
-> ApiEnvironment.MOCK to "[REDACTED_ENV_URL]"
DEBUG_BUILD_TYPE,
-> ApiEnvironment.DEV to "[REDACTED_ENV_URL]"
INTERNAL_BUILD_TYPE,

View file

@ -14,7 +14,7 @@ import io.mockk.coVerifyOrder
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Test
/**
[REDACTED_AUTHOR]

View file

@ -5,7 +5,7 @@ import com.google.common.truth.Truth
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Test
import java.io.IOException
/**

View file

@ -0,0 +1,107 @@
package com.tangem.datasource.local.card
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.Moshi
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
/**
* Tests Moshi serialization/deserialization of [UsedCardInfo].
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class UsedCardInfoSerializationTest {
private val adapter = Moshi.Builder().build().adapter(UsedCardInfo::class.java)
@Test
fun `GIVEN full model WHEN toJson THEN all fields serialized in declaration order`() {
// Arrange
val model = UsedCardInfo(
cardId = "card-1",
isScanned = true,
isActivationStarted = true,
isActivationFinished = false,
hasBackupError = true,
)
// Act
val json = adapter.toJson(model)
// Assert
assertThat(json).isEqualTo(
"""{"cardId":"card-1","isScanned":true,"isActivationStarted":true,""" +
""""isActivationFinished":false,"hasBackupError":true}""",
)
}
@Test
fun `GIVEN full json WHEN fromJson THEN model fully populated`() {
// Arrange
val json = """{"cardId":"card-2","isScanned":false,"isActivationStarted":true,""" +
""""isActivationFinished":true,"hasBackupError":false}"""
// Act
val result = adapter.fromJson(json)
// Assert
assertThat(result).isEqualTo(
UsedCardInfo(
cardId = "card-2",
isScanned = false,
isActivationStarted = true,
isActivationFinished = true,
hasBackupError = false,
),
)
}
@Test
fun `GIVEN json with only cardId WHEN fromJson THEN boolean fields fall back to defaults`() {
// Arrange
val json = """{"cardId":"card-3"}"""
// Act
val result = adapter.fromJson(json)
// Assert
assertThat(result).isEqualTo(UsedCardInfo(cardId = "card-3"))
}
@Test
fun `GIVEN json without cardId WHEN fromJson THEN throws`() {
// Arrange
val json = """{"isScanned":true}"""
// Act
val error = runCatching { adapter.fromJson(json) }.exceptionOrNull()
// Assert
assertThat(error).isInstanceOf(JsonDataException::class.java)
}
@ParameterizedTest
@ProvideTestModels
fun roundTrip(model: UsedCardInfo) {
// Act
val restored = adapter.fromJson(adapter.toJson(model))
// Assert
assertThat(restored).isEqualTo(model)
}
private fun provideTestModels() = listOf(
UsedCardInfo(cardId = "default-only"),
UsedCardInfo(
cardId = "all-true",
isScanned = true,
isActivationStarted = true,
isActivationFinished = true,
hasBackupError = true,
),
UsedCardInfo(cardId = "activation-in-progress", isScanned = true, isActivationStarted = true),
UsedCardInfo(cardId = "backup-error", hasBackupError = true),
)
}

View file

@ -0,0 +1,272 @@
package com.tangem.datasource.local.converter
import com.google.common.truth.Truth
import com.tangem.datasource.api.express.models.response.ExchangeItemResponse
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ExpressHistoryConverterTest {
@Test
fun `GIVEN exchange item WHEN toEntity THEN all transaction fields are mapped`() {
// GIVEN
val item = createExchangeItem()
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// THEN
Truth.assertThat(entity.txId).isEqualTo(item.txId)
Truth.assertThat(entity.ownerAddress).isEqualTo(OWNER_ADDRESS)
Truth.assertThat(entity.providerId).isEqualTo(item.providerId)
Truth.assertThat(entity.fromAddress).isEqualTo(item.fromAddress)
Truth.assertThat(entity.payinAddress).isEqualTo(item.payinAddress)
Truth.assertThat(entity.payinExtraId).isEqualTo(item.payinExtraId)
Truth.assertThat(entity.payoutAddress).isEqualTo(item.payoutAddress)
Truth.assertThat(entity.refundAddress).isEqualTo(item.refundAddress)
Truth.assertThat(entity.refundExtraId).isEqualTo(item.refundExtraId)
Truth.assertThat(entity.rateType).isEqualTo(item.rateType)
Truth.assertThat(entity.externalTxId).isEqualTo(item.externalTxId)
Truth.assertThat(entity.externalTxUrl).isEqualTo(item.externalTxUrl)
Truth.assertThat(entity.payinHash).isEqualTo(item.payinHash)
Truth.assertThat(entity.payoutHash).isEqualTo(item.payoutHash)
Truth.assertThat(entity.refundNetwork).isEqualTo(item.refundNetwork)
Truth.assertThat(entity.refundContractAddress).isEqualTo(item.refundContractAddress)
Truth.assertThat(entity.createdAt).isEqualTo(item.createdAt)
Truth.assertThat(entity.updatedAt).isEqualTo(item.updatedAt)
Truth.assertThat(entity.payTill).isEqualTo(item.payTill)
Truth.assertThat(entity.averageDuration).isEqualTo(item.averageDuration)
}
@Test
fun `GIVEN exchange item WHEN toEntity THEN status is stored as raw string`() {
// GIVEN
val item = createExchangeItem(status = "finished")
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// THEN
Truth.assertThat(entity.status).isEqualTo("finished")
}
@Test
fun `GIVEN exchange item WHEN toEntity THEN from and to assets are mapped`() {
// GIVEN
val item = createExchangeItem()
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// THEN
Truth.assertThat(entity.from.contractAddress).isEqualTo(item.fromContractAddress)
Truth.assertThat(entity.from.network).isEqualTo(item.fromNetwork)
Truth.assertThat(entity.from.decimals).isEqualTo(item.fromDecimals)
Truth.assertThat(entity.from.amount).isEqualTo(item.fromAmount)
// `from` asset never carries an actual amount
Truth.assertThat(entity.from.actualAmount).isNull()
Truth.assertThat(entity.to.contractAddress).isEqualTo(item.toContractAddress)
Truth.assertThat(entity.to.network).isEqualTo(item.toNetwork)
Truth.assertThat(entity.to.decimals).isEqualTo(item.toDecimals)
Truth.assertThat(entity.to.amount).isEqualTo(item.toAmount)
Truth.assertThat(entity.to.actualAmount).isEqualTo(item.toActualAmount)
}
@Test
fun `GIVEN exchange item with null optional fields WHEN toEntity THEN nulls are preserved`() {
// GIVEN
val item = createExchangeItem(
payinExtraId = null,
refundAddress = null,
refundExtraId = null,
externalTxId = null,
externalTxUrl = null,
payinHash = null,
payoutHash = null,
refundNetwork = null,
refundContractAddress = null,
payTill = null,
averageDuration = null,
toActualAmount = null,
)
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// THEN
Truth.assertThat(entity.payinExtraId).isNull()
Truth.assertThat(entity.refundAddress).isNull()
Truth.assertThat(entity.refundExtraId).isNull()
Truth.assertThat(entity.externalTxId).isNull()
Truth.assertThat(entity.externalTxUrl).isNull()
Truth.assertThat(entity.payinHash).isNull()
Truth.assertThat(entity.payoutHash).isNull()
Truth.assertThat(entity.refundNetwork).isNull()
Truth.assertThat(entity.refundContractAddress).isNull()
Truth.assertThat(entity.payTill).isNull()
Truth.assertThat(entity.averageDuration).isNull()
Truth.assertThat(entity.to.actualAmount).isNull()
}
@Test
fun `GIVEN onramp item WHEN toEntity THEN all transaction fields are mapped`() {
// GIVEN
val item = createOnrampItem()
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// THEN
Truth.assertThat(entity.txId).isEqualTo(item.txId)
Truth.assertThat(entity.ownerAddress).isEqualTo(OWNER_ADDRESS)
Truth.assertThat(entity.providerId).isEqualTo(item.providerId)
Truth.assertThat(entity.payoutAddress).isEqualTo(item.payoutAddress)
Truth.assertThat(entity.failReason).isEqualTo(item.failReason)
Truth.assertThat(entity.externalTxId).isEqualTo(item.externalTxId)
Truth.assertThat(entity.externalTxUrl).isEqualTo(item.externalTxUrl)
Truth.assertThat(entity.payoutHash).isEqualTo(item.payoutHash)
Truth.assertThat(entity.createdAt).isEqualTo(item.createdAt)
Truth.assertThat(entity.updatedAt).isEqualTo(item.updatedAt)
Truth.assertThat(entity.fromCurrencyCode).isEqualTo(item.fromCurrencyCode)
Truth.assertThat(entity.fromAmount).isEqualTo(item.fromAmount)
Truth.assertThat(entity.fromPrecision).isEqualTo(item.fromPrecision)
Truth.assertThat(entity.paymentMethod).isEqualTo(item.paymentMethod)
Truth.assertThat(entity.countryCode).isEqualTo(item.countryCode)
}
@Test
fun `GIVEN onramp item WHEN toEntity THEN status is stored as raw string`() {
// GIVEN
val item = createOnrampItem(status = "waiting-for-payment")
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// THEN
Truth.assertThat(entity.status).isEqualTo("waiting-for-payment")
}
@Test
fun `GIVEN onramp item WHEN toEntity THEN to asset is mapped`() {
// GIVEN
val item = createOnrampItem()
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// THEN
Truth.assertThat(entity.to.contractAddress).isEqualTo(item.toContractAddress)
Truth.assertThat(entity.to.network).isEqualTo(item.toNetwork)
Truth.assertThat(entity.to.decimals).isEqualTo(item.toDecimals)
Truth.assertThat(entity.to.amount).isEqualTo(item.toAmount)
Truth.assertThat(entity.to.actualAmount).isEqualTo(item.toActualAmount)
}
@Test
fun `GIVEN onramp item with null optional fields WHEN toEntity THEN nulls are preserved`() {
// GIVEN
val item = createOnrampItem(
failReason = null,
externalTxId = null,
externalTxUrl = null,
payoutHash = null,
toAmount = null,
toActualAmount = null,
)
// WHEN
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
// THEN
Truth.assertThat(entity.failReason).isNull()
Truth.assertThat(entity.externalTxId).isNull()
Truth.assertThat(entity.externalTxUrl).isNull()
Truth.assertThat(entity.payoutHash).isNull()
Truth.assertThat(entity.to.amount).isNull()
Truth.assertThat(entity.to.actualAmount).isNull()
}
private fun createExchangeItem(
status: String = "waiting",
payinExtraId: String? = "payin-extra",
refundAddress: String? = "refund-address",
refundExtraId: String? = "refund-extra",
externalTxId: String? = "external-tx-id",
externalTxUrl: String? = "https://provider.example/tx",
payinHash: String? = "payin-hash",
payoutHash: String? = "payout-hash",
refundNetwork: String? = "ethereum",
refundContractAddress: String? = "0xrefund",
payTill: String? = "2026-06-01T00:10:00Z",
averageDuration: Long? = 600L,
toActualAmount: String? = "0.99",
) = ExchangeItemResponse(
txId = "exchange-tx-1",
providerId = "changelly",
fromAddress = "0xfrom",
payinAddress = "0xpayin",
payinExtraId = payinExtraId,
payoutAddress = "0xpayout",
refundAddress = refundAddress,
refundExtraId = refundExtraId,
rateType = "float",
status = status,
externalTxId = externalTxId,
externalTxUrl = externalTxUrl,
payinHash = payinHash,
payoutHash = payoutHash,
refundNetwork = refundNetwork,
refundContractAddress = refundContractAddress,
createdAt = "2026-06-01T00:00:00Z",
updatedAt = "2026-06-01T00:05:00Z",
payTill = payTill,
averageDuration = averageDuration,
fromContractAddress = "0xfromContract",
fromNetwork = "ethereum",
fromDecimals = 18,
fromAmount = "1.0",
toContractAddress = "0xtoContract",
toNetwork = "bitcoin",
toDecimals = 8,
toAmount = "1.0",
toActualAmount = toActualAmount,
)
private fun createOnrampItem(
status: String = "waiting-for-payment",
failReason: String? = "fail-reason",
externalTxId: String? = "external-tx-id",
externalTxUrl: String? = "https://provider.example/tx",
payoutHash: String? = "payout-hash",
toAmount: String? = "0.001",
toActualAmount: String? = "0.00099",
) = OnrampItemResponse(
txId = "onramp-tx-1",
providerId = "mercuryo",
payoutAddress = "0xpayout",
status = status,
failReason = failReason,
externalTxId = externalTxId,
externalTxUrl = externalTxUrl,
payoutHash = payoutHash,
createdAt = "2026-06-01T00:00:00Z",
updatedAt = "2026-06-01T00:05:00Z",
fromCurrencyCode = "USD",
fromAmount = "100.0",
fromPrecision = 2,
toContractAddress = "0xtoContract",
toNetwork = "bitcoin",
toDecimals = 8,
toAmount = toAmount,
toActualAmount = toActualAmount,
paymentMethod = "card",
countryCode = "US",
)
private companion object {
const val OWNER_ADDRESS = "0xowner"
}
}

View file

@ -0,0 +1,107 @@
package com.tangem.datasource.local.logs
import com.google.common.truth.Truth
import com.tangem.datasource.local.logs.SensitiveUrlMasker.Companion.MASKED_VALUE
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SensitiveUrlMaskerTest {
@ParameterizedTest
@ProvideTestModels
fun mask(model: TestModel) {
// Arrange
val masker = SensitiveUrlMasker(model.sensitiveValues)
// Act
val actual = masker.mask(model.input)
// Assert
Truth.assertThat(actual).isEqualTo(model.expected)
}
@Test
fun `mask returns url unchanged when no sensitive values provided`() {
// Arrange
val masker = SensitiveUrlMasker(emptyList())
val url = "https://api.tangem.com/v1/cards/abc123"
// Act
val actual = masker.mask(url)
// Assert
Truth.assertThat(actual).isEqualTo(url)
}
@Test
fun `constructor deduplicates input values`() {
// Arrange — same secret repeated; if no dedup, replace would be invoked twice
// (idempotent on already-masked string, but we assert behavior is identical
// to a single-value masker as a smoke-check)
val withDuplicates = SensitiveUrlMasker(listOf("secret123", "secret123", "secret123"))
val withSingle = SensitiveUrlMasker(listOf("secret123"))
val url = "https://api.tangem.com/?key=secret123"
// Act
val withDup = withDuplicates.mask(url)
val withSingleResult = withSingle.mask(url)
// Assert
Truth.assertThat(withDup).isEqualTo(withSingleResult)
Truth.assertThat(withDup).isEqualTo("https://api.tangem.com/?key=$MASKED_VALUE")
}
private fun provideTestModels() = listOf(
TestModel(
input = "https://api.tangem.com/?key=secret123",
sensitiveValues = listOf("secret123"),
expected = "https://api.tangem.com/?key=$MASKED_VALUE",
),
TestModel(
input = "https://api.tangem.com/?a=alpha&b=beta",
sensitiveValues = listOf("alpha", "beta"),
expected = "https://api.tangem.com/?a=$MASKED_VALUE&b=$MASKED_VALUE",
),
TestModel(
input = "https://api.tangem.com/?key=SECRET123",
sensitiveValues = listOf("secret123"),
expected = "https://api.tangem.com/?key=$MASKED_VALUE",
),
TestModel(
input = "https://api.tangem.com/v1/balance",
sensitiveValues = listOf("notInUrl"),
expected = "https://api.tangem.com/v1/balance",
),
TestModel(
input = "https://api.tangem.com/?key=secret123&other=secret123",
sensitiveValues = listOf("secret123"),
expected = "https://api.tangem.com/?key=$MASKED_VALUE&other=$MASKED_VALUE",
),
TestModel(
input = "https://api.tangem.com/v1/cards",
sensitiveValues = emptyList(),
expected = "https://api.tangem.com/v1/cards",
),
// Regression: when one value is a prefix of another, the longer one must be masked first
// regardless of input order, otherwise the suffix leaks (e.g. "my-node-prod" -> "******-prod").
TestModel(
input = "https://my-node-prod.example.com/v1",
sensitiveValues = listOf("my-node", "my-node-prod"),
expected = "https://$MASKED_VALUE.example.com/v1",
),
TestModel(
input = "https://my-node-prod.example.com/v1",
sensitiveValues = listOf("my-node-prod", "my-node"),
expected = "https://$MASKED_VALUE.example.com/v1",
),
)
data class TestModel(
val input: String,
val sensitiveValues: List<String>,
val expected: String,
)
}

View file

@ -6,7 +6,7 @@ import com.squareup.moshi.adapter
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import com.tangem.datasource.api.common.adapter.BigDecimalAdapter
import dev.onenowy.moshipolymorphicadapter.NamePolymorphicAdapterFactory
import org.junit.Test
import org.junit.jupiter.api.Test
import java.math.BigDecimal
/**
@ -42,10 +42,12 @@ class NetworkStatusDMSerializationTest {
{ "value": "0x123456", "type": "primary" },
{ "value": "0xabcdef", "type": "secondary" }
],
"amounts": { "ETH": "1.2345" },
"yield_supply_statuses": {
"ETH": { "is_active": false, "is_initialized": false, "is_allowed_to_spend": false }
}
"amounts": [
{ "id": { "value": "ethereum" }, "amount": "1.2345" }
],
"yield_supply_statuses": [
{ "id": { "value": "ethereum" }, "is_active": false, "is_initialized": false, "is_allowed_to_spend": false }
]
}
""".trimIndent()
@ -129,10 +131,12 @@ class NetworkStatusDMSerializationTest {
{ "value": "0x123456", "type": "primary" },
{ "value": "0xabcdef", "type": "secondary" }
],
"amounts": { "ETH": "1.2345" },
"yield_supply_statuses": {
"ETH": { "is_active": false, "is_initialized": false, "is_allowed_to_spend": false }
}
"amounts": [
{ "id": { "value": "ethereum" }, "amount": "1.2345" }
],
"yield_supply_statuses": [
{ "id": { "value": "ethereum" }, "is_active": false, "is_initialized": false, "is_allowed_to_spend": false }
]
}
""".stripJsonWhitespace()

View file

@ -0,0 +1,74 @@
package com.tangem.datasource.local.visa.entity
import com.google.common.truth.Truth
import com.squareup.moshi.adapter
import com.tangem.datasource.di.MoshiModule
import org.junit.jupiter.api.Test
import java.math.BigDecimal
/**
* Verifies that the network Moshi (see [MoshiModule.provideNetworkMoshi]) resolves and round-trips the
* polymorphic [VirtualAccountStatusValueDM] adapter.
*
* Guards against the missing `NamePolymorphicAdapterFactory` registration that caused a runtime
* `ClassNotFoundException: ...VirtualAccountStatusValueDMJsonAdapter` (the adapter is registered manually,
* not generated).
*/
class VirtualAccountStatusValueDMSerializationTest {
@OptIn(ExperimentalStdlibApi::class)
private val adapter = MoshiModule().provideNetworkMoshi().adapter<VirtualAccountStatusValueDM>()
@Test
fun `round-trip NotCreated`() {
val model: VirtualAccountStatusValueDM = VirtualAccountStatusValueDM.NotCreated()
val restored = adapter.fromJson(adapter.toJson(model))
Truth.assertThat(restored).isInstanceOf(VirtualAccountStatusValueDM.NotCreated::class.java)
}
@Test
fun `round-trip Provisioning`() {
val model: VirtualAccountStatusValueDM = VirtualAccountStatusValueDM.Provisioning()
val restored = adapter.fromJson(adapter.toJson(model))
Truth.assertThat(restored).isInstanceOf(VirtualAccountStatusValueDM.Provisioning::class.java)
}
@Test
fun `round-trip CountryNotSupported`() {
val model: VirtualAccountStatusValueDM = VirtualAccountStatusValueDM.CountryNotSupported()
val restored = adapter.fromJson(adapter.toJson(model))
Truth.assertThat(restored).isInstanceOf(VirtualAccountStatusValueDM.CountryNotSupported::class.java)
}
@Test
fun `round-trip ActiveAccount`() {
val model: VirtualAccountStatusValueDM = VirtualAccountStatusValueDM.ActiveAccount(
customerId = "cust-1",
currencyCode = "USD",
depositAddress = "0xabc",
fiatBalance = VirtualAccountStatusValueDM.FiatBalanceDM(
availableBalance = BigDecimal("101.56"),
currency = "USD",
),
cryptoBalance = VirtualAccountStatusValueDM.CryptoBalanceDM(
id = "usd-coin",
chainId = 137L,
depositAddress = "0xabc",
tokenContractAddress = "0xdef",
balance = BigDecimal("101.56"),
),
fiatRate = BigDecimal("0.95"),
availableForWithdrawal = BigDecimal("100.00"),
)
val restored = adapter.fromJson(adapter.toJson(model))
Truth.assertThat(restored).isEqualTo(model)
}
}