Updated on 2026-08-14
This commit is contained in:
commit
3aaf9c5b0a
2074 changed files with 88068 additions and 12659 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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?,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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/"
|
||||
}
|
||||
}
|
||||
|
|
@ -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]"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
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 */
|
||||
// todo txHistory uncomment
|
||||
/*
|
||||
@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
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
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 */
|
||||
// todo txHistory uncomment
|
||||
/*
|
||||
@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,
|
||||
)
|
||||
|
|
@ -46,30 +46,50 @@ 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>
|
||||
|
||||
@GET("v1/customer/card/pin")
|
||||
@GET("v1/customer/card/{card_id}/pin")
|
||||
suspend fun getPin(
|
||||
@Header("Authorization") authHeader: String,
|
||||
@Path("card_id") cardId: String,
|
||||
@Header("X-Session-Id") sessionId: String,
|
||||
): ApiResponse<GetPinResponse>
|
||||
|
||||
@PUT("v1/customer/card/pin")
|
||||
@PUT("v1/customer/card/{card_id}/pin")
|
||||
suspend fun setPin(
|
||||
@Header("Authorization") authHeader: String,
|
||||
@Path("card_id") cardId: String,
|
||||
@Body body: SetPinRequest,
|
||||
): ApiResponse<SetPinResponse>
|
||||
|
||||
|
|
@ -97,6 +117,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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -113,4 +113,10 @@ internal object ApiConfigsModule {
|
|||
fun provideSurveySparrowConfig(environmentConfig: EnvironmentConfig): ApiConfig {
|
||||
return SurveySparrow(environmentConfig)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@IntoSet
|
||||
fun provideAuthConfig(): ApiConfig {
|
||||
return Auth()
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -3,8 +3,12 @@ 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.DefaultTangemPayIssueCardStore
|
||||
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.TangemPayIssueCardStore
|
||||
import com.tangem.datasource.local.visa.TangemPayReissueCardStore
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
|
|
@ -32,4 +36,20 @@ internal object TangemPayStoresModule {
|
|||
prefs = prefs,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTangemPayCloseCardStore(prefs: AppPreferencesStore): TangemPayCloseCardStore {
|
||||
return DefaultTangemPayCloseCardStore(
|
||||
prefs = prefs,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTangemPayIssueCardStore(prefs: AppPreferencesStore): TangemPayIssueCardStore {
|
||||
return DefaultTangemPayIssueCardStore(
|
||||
prefs = prefs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -56,6 +56,7 @@ internal object GeneratedEnvironmentConfigConverter {
|
|||
customerIoCdpApiKey = GeneratedEnvironmentConfig.CustomerIO.androidApiKey,
|
||||
surveySparrowToken = GeneratedEnvironmentConfig.SurveySparrow.apiKey,
|
||||
surveySparrowSwapRating = createSurveySparrowSwapRating(),
|
||||
authServiceKey = GeneratedEnvironmentConfig.authServiceKey,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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*/, // todo txHistory uncomment
|
||||
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*/, // todo txHistory uncomment,
|
||||
fromCurrencyCode = fromCurrencyCode,
|
||||
fromAmount = fromAmount,
|
||||
fromPrecision = fromPrecision,
|
||||
to = ExpressOnrampEntity.AssetEmbedded(
|
||||
contractAddress = toContractAddress,
|
||||
network = toNetwork,
|
||||
decimals = toDecimals,
|
||||
amount = toAmount,
|
||||
actualAmount = toActualAmount,
|
||||
),
|
||||
paymentMethod = paymentMethod,
|
||||
countryCode = countryCode,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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 = "******"
|
||||
}
|
||||
}
|
||||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>>
|
||||
}
|
||||
|
|
@ -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?>
|
||||
}
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -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?,
|
||||
)
|
||||
}
|
||||
|
|
@ -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?,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.tangem.datasource.local.visa
|
||||
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.preferences.utils.getObjectListSync
|
||||
import com.tangem.datasource.local.preferences.utils.storeObjectList
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
internal class DefaultTangemPayIssueCardStore(
|
||||
private val prefs: AppPreferencesStore,
|
||||
) : TangemPayIssueCardStore {
|
||||
|
||||
override suspend fun addIssueOrderId(userWalletId: UserWalletId, orderId: String) {
|
||||
val current = prefs.getObjectListSync<String>(getKey(userWalletId))
|
||||
if (orderId !in current) {
|
||||
prefs.storeObjectList(key = getKey(userWalletId), value = current + orderId)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getIssueOrderIds(userWalletId: UserWalletId): List<String> {
|
||||
return prefs.getObjectListSync(getKey(userWalletId))
|
||||
}
|
||||
|
||||
override suspend fun removeIssueOrderId(userWalletId: UserWalletId, orderId: String) {
|
||||
val current = prefs.getObjectListSync<String>(getKey(userWalletId))
|
||||
if (orderId in current) {
|
||||
prefs.storeObjectList(key = getKey(userWalletId), value = current - orderId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getKey(userWalletId: UserWalletId) =
|
||||
stringPreferencesKey("tangem_pay_issue_card_orders_${userWalletId.stringValue}")
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.tangem.datasource.local.visa
|
||||
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
/**
|
||||
* Local store for additional-card issuance order ids.
|
||||
*
|
||||
* The backend `customer/me` response does not surface a card while it is still being issued, so the
|
||||
* order id of an in-flight additional-card issuance is persisted here (per wallet) to render an
|
||||
* "issuing" placeholder card until the order reaches a terminal state and the real card appears.
|
||||
*/
|
||||
interface TangemPayIssueCardStore {
|
||||
|
||||
suspend fun addIssueOrderId(userWalletId: UserWalletId, orderId: String)
|
||||
|
||||
suspend fun getIssueOrderIds(userWalletId: UserWalletId): List<String>
|
||||
|
||||
suspend fun removeIssueOrderId(userWalletId: UserWalletId, orderId: String)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ class ApiConfigTest {
|
|||
appInfoProvider = mockk(),
|
||||
)
|
||||
ApiConfig.ID.SurveySparrow -> SurveySparrow(environmentConfig = environmentConfig)
|
||||
ApiConfig.ID.Auth -> Auth()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
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)
|
||||
// todo txHistory uncomment
|
||||
// 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)
|
||||
// todo txHistory uncomment
|
||||
// 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",
|
||||
// todo txHistory uncomment
|
||||
// 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",
|
||||
// todo txHistory uncomment
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue