Skip to content

Commit

Permalink
[#397] Add ZIP 321 URI parser
Browse files Browse the repository at this point in the history
adds a transaction proposal API for URI strings

Closes #397

Add `proposeFulfillingPaymentUri` to changelog

Fix lib.rs compile errors

Changelog update

Suppress detekt warning

Fix ktlint warnings

Improve proposal functions error reporting

Simple get-proposal-from-uri Demo app use case

Add ZIP 321 Uri examples

Changelog update

Address review comments

Address FFI review comment
  • Loading branch information
pacu authored Oct 3, 2024
1 parent 0fb6b78 commit a13af0a
Show file tree
Hide file tree
Showing 17 changed files with 368 additions and 36 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ and this library adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- The new `Synchronizer.proposeFulfillingPaymentUri` API has been added. It enables constructing Proposal object from
the given ZIP-321 Uri, and then creating transactions from it.

### Changed
- `Synchronizer.proposeTransfer` throws `TransactionEncoderException.ProposalFromParametersException`
- `Synchronizer.proposeShielding` throws `TransactionEncoderException.ProposalShieldingException`
- `Synchronizer.createProposedTransactions` throws `TransactionEncoderException.TransactionNotCreatedException` and `TransactionEncoderException.TransactionNotFoundException`

## [2.2.4] - 2024-09-16

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ interface Backend {
memo: ByteArray? = null
): ProposalUnsafe

/**
* @throws RuntimeException as a common indicator of the operation failure
*/
@Throws(RuntimeException::class)
suspend fun proposeTransferFromUri(
account: Int,
uri: String
): ProposalUnsafe

suspend fun proposeShielding(
account: Int,
shieldingThreshold: Long,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,21 @@ class RustBackend private constructor(
)
}

override suspend fun proposeTransferFromUri(
account: Int,
uri: String
): ProposalUnsafe =
withContext(SdkDispatchers.DATABASE_IO) {
ProposalUnsafe.parse(
proposeTransferFromUri(
dataDbFile.absolutePath,
account,
uri,
networkId = networkId,
)
)
}

override suspend fun proposeTransfer(
account: Int,
to: String,
Expand All @@ -332,7 +347,6 @@ class RustBackend private constructor(
value,
memo,
networkId = networkId,
useZip317Fees = IS_USE_ZIP_317_FEES
)
)
}
Expand All @@ -351,7 +365,6 @@ class RustBackend private constructor(
memo,
transparentReceiver,
networkId = networkId,
useZip317Fees = IS_USE_ZIP_317_FEES
)?.let {
ProposalUnsafe.parse(
it
Expand Down Expand Up @@ -422,7 +435,6 @@ class RustBackend private constructor(
*/
companion object {
internal val rustLibraryLoader = NativeLibraryLoader("zcashwalletsdk")
private const val IS_USE_ZIP_317_FEES = true

private val rustLogging: RustLogging = RustLogging.Off

Expand Down Expand Up @@ -659,6 +671,14 @@ class RustBackend private constructor(
networkId: Int
)

@JvmStatic
private external fun proposeTransferFromUri(
dbDataPath: String,
account: Int,
uri: String,
networkId: Int,
): ByteArray

@JvmStatic
@Suppress("LongParameterList")
private external fun proposeTransfer(
Expand All @@ -668,7 +688,6 @@ class RustBackend private constructor(
value: Long,
memo: ByteArray?,
networkId: Int,
useZip317Fees: Boolean
): ByteArray

@JvmStatic
Expand All @@ -680,7 +699,6 @@ class RustBackend private constructor(
memo: ByteArray?,
transparentReceiver: String?,
networkId: Int,
useZip317Fees: Boolean
): ByteArray?

@JvmStatic
Expand Down
60 changes: 48 additions & 12 deletions backend-lib/src/main/rust/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1700,20 +1700,56 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_setTransa

fn zip317_helper<DbT>(
change_memo: Option<MemoBytes>,
use_zip317_fees: jboolean,
) -> GreedyInputSelector<DbT, SingleOutputChangeStrategy> {
let fee_rule = if use_zip317_fees == JNI_TRUE {
StandardFeeRule::Zip317
} else {
#[allow(deprecated)]
StandardFeeRule::PreZip313
};
GreedyInputSelector::new(
SingleOutputChangeStrategy::new(fee_rule, change_memo, ShieldedProtocol::Orchard),
SingleOutputChangeStrategy::new(StandardFeeRule::Zip317, change_memo, ShieldedProtocol::Orchard),
DustOutputPolicy::default(),
)
}

#[no_mangle]
pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_proposeTransferFromUri<'local>(
mut env: JNIEnv<'local>,
_: JClass<'local>,
db_data: JString<'local>,
account: jint,
payment_uri: JString<'local>,
network_id: jint,
) -> jbyteArray {
let res = catch_unwind(&mut env, |env| {
let _span = tracing::info_span!("RustBackend.proposeTransfer").entered();
let network = parse_network(network_id as u32)?;
let mut db_data = wallet_db(env, network, db_data)?;
let account = account_id_from_jni(&db_data, account)?;
let payment_uri = utils::java_string_to_rust(env, &payment_uri);

// Always use ZIP 317 fees
let input_selector = zip317_helper(None);

let request = TransactionRequest::from_uri(&payment_uri)
.map_err(|e| anyhow!("Error creating transaction request: {:?}", e))?;

let proposal = propose_transfer::<_, _, _, Infallible>(
&mut db_data,
&network,
account,
&input_selector,
request,
ANCHOR_OFFSET,
)
.map_err(|e| anyhow!("Error creating transaction proposal: {}", e))?;

Ok(utils::rust_bytes_to_java(
env,
Proposal::from_standard_proposal(&proposal)
.encode_to_vec()
.as_ref(),
)?
.into_raw())
});
unwrap_exc_or(&mut env, res, ptr::null_mut())
}

#[no_mangle]
pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_proposeTransfer<'local>(
mut env: JNIEnv<'local>,
Expand All @@ -1724,7 +1760,6 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_proposeTr
value: jlong,
memo: JByteArray<'local>,
network_id: jint,
use_zip317_fees: jboolean,
) -> jbyteArray {
let res = catch_unwind(&mut env, |env| {
let _span = tracing::info_span!("RustBackend.proposeTransfer").entered();
Expand All @@ -1747,7 +1782,8 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_proposeTr
.map_err(|e| anyhow!("Invalid MemoBytes: {}", e))?
};

let input_selector = zip317_helper(None, use_zip317_fees);
// Always use ZIP 317 fees
let input_selector = zip317_helper(None);

let request =
TransactionRequest::new(vec![Payment::new(to, value, memo, None, None, vec![])
Expand Down Expand Up @@ -1787,7 +1823,6 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_proposeSh
memo: JByteArray<'local>,
transparent_receiver: JString<'local>,
network_id: jint,
use_zip317_fees: jboolean,
) -> jbyteArray {
let res = catch_unwind(&mut env, |env| {
let _span = tracing::info_span!("RustBackend.proposeShielding").entered();
Expand Down Expand Up @@ -1867,7 +1902,8 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustBackend_proposeSh
.map_err(|e| anyhow!("Invalid MemoBytes: {}", e))?
};

let input_selector = zip317_helper(memo, use_zip317_fees);
// Always use ZIP 317 fees
let input_selector = zip317_helper(memo);

let proposal = propose_shielding::<_, _, _, Infallible>(
&mut db_data,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ internal fun ComposeActivity.Navigation() {

val sendTransactionProposal = remember { mutableStateOf<Proposal?>(null) }

val sendTransactionProposalFromUri = remember { mutableStateOf<Proposal?>(null) }

if (null == synchronizer || null == walletSnapshot || null == spendingKey) {
// Display loading indicator
} else {
Expand All @@ -168,11 +170,15 @@ internal fun ComposeActivity.Navigation() {
onGetProposal = {
sendTransactionProposal.value = walletViewModel.getSendProposal(it)
},
onGetProposalFromUri = {
sendTransactionProposalFromUri.value = walletViewModel.getSendProposalFromUri(it)
},
onBack = {
walletViewModel.clearSendOrShieldState()
navController.popBackStackJustOnce(SEND)
},
sendTransactionProposal = sendTransactionProposal.value
sendTransactionProposal = sendTransactionProposal.value,
sendTransactionProposalFromUri = sendTransactionProposalFromUri.value
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,31 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
}
}

/**
* Synchronously provides proposal object for the given [spendingKey] and [uri] objects
*/
fun getSendProposalFromUri(uri: String): Proposal? {
if (sendState.value is SendState.Sending) {
return null
}

val synchronizer = synchronizer.value

return if (null != synchronizer) {
// Calling the proposal API within a blocking coroutine should be fine for the showcase purpose
runBlocking {
val spendingKey = spendingKey.filterNotNull().first()
kotlin.runCatching {
synchronizer.proposeFulfillingPaymentUri(spendingKey.account, uri)
}.onFailure {
Twig.error(it) { "Failed to get transaction proposal from uri" }
}.getOrNull()
}
} else {
error("Unable to send funds because synchronizer is not loaded.")
}
}

/**
* Asynchronously shields transparent funds. Note that two shielding operations cannot occur at the same time.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
Expand Down Expand Up @@ -63,8 +64,10 @@ private fun ComposablePreview() {
sendState = SendState.None,
onSend = {},
onGetProposal = {},
onGetProposalFromUri = {},
onBack = {},
sendTransactionProposal = null
sendTransactionProposal = null,
sendTransactionProposalFromUri = null
)
}
}
Expand All @@ -76,8 +79,10 @@ fun Send(
sendState: SendState,
onSend: (ZecSend) -> Unit,
onGetProposal: (ZecSend) -> Unit,
onGetProposalFromUri: (String) -> Unit,
onBack: () -> Unit,
sendTransactionProposal: Proposal?,
sendTransactionProposalFromUri: Proposal?,
) {
Scaffold(topBar = {
SendTopAppBar(onBack)
Expand All @@ -88,7 +93,9 @@ fun Send(
sendState = sendState,
onSend = onSend,
onGetProposal = onGetProposal,
sendTransactionProposal = sendTransactionProposal
onGetProposalFromUri = onGetProposalFromUri,
sendTransactionProposal = sendTransactionProposal,
sendTransactionProposalFromUri = sendTransactionProposalFromUri
)
}
}
Expand Down Expand Up @@ -120,7 +127,9 @@ private fun SendMainContent(
sendState: SendState,
onSend: (ZecSend) -> Unit,
onGetProposal: (ZecSend) -> Unit,
onGetProposalFromUri: (String) -> Unit,
sendTransactionProposal: Proposal?,
sendTransactionProposalFromUri: Proposal?,
) {
val context = LocalContext.current
val monetarySeparators = MonetarySeparators.current(locale = Locale.US)
Expand All @@ -134,6 +143,8 @@ private fun SendMainContent(
}
var memoString by rememberSaveable { mutableStateOf("") }

var zip321String by rememberSaveable { mutableStateOf("") }

var validation by rememberSaveable {
mutableStateOf<Set<ZecSendExt.ZecSendValidation.Invalid.ValidationError>>(emptySet())
}
Expand Down Expand Up @@ -289,6 +300,50 @@ private fun SendMainContent(
Text(stringResource(id = R.string.send_button))
}

Spacer(modifier = Modifier.height(16.dp))

HorizontalDivider()

Spacer(modifier = Modifier.height(16.dp))

// ZIP 321 URI examples for Alice's addresses:
//
// A valid payment request for a payment of 1 ZEC to a single shielded Sapling address, with a
// base64url-encoded memo and a message for display by the wallet:
// zcash:zs15tzaulx5weua5c7l47l4pku2pw9fzwvvnsp4y80jdpul0y3nwn5zp7tmkcclqaca3mdjqjkl7hx?amount=0.0001
// &memo=VGhpcyBpcyBhIHNpbXBsZSBtZW1vLg&message=Thank%20you%20for%20your%20purchase
//
// A valid payment request with one transparent and one shielded Sapling recipient address, with a
// base64url-encoded Unicode memo for the shielded recipient:
// zcash:?address=t1duiEGg7b39nfQee3XaTY4f5McqfyJKhBi&amount=0.0001
// &address.1=zs15tzaulx5weua5c7l47l4pku2pw9fzwvvnsp4y80jdpul0y3nwn5zp7tmkcclqaca3mdjqjkl7hx
// &amount.1=0.0002&memo.1=VGhpcyBpcyBhIHVuaWNvZGUgbWVtbyDinKjwn6aE8J-PhvCfjok

TextField(
value = zip321String,
onValueChange = { zip321String = it },
label = { Text(stringResource(id = R.string.send_zip_321_uri)) }
)

if (sendTransactionProposalFromUri != null) {
Text(stringResource(id = R.string.send_proposal_status, sendTransactionProposalFromUri.toPrettyString()))
}

Button(
onClick = {
onGetProposalFromUri(zip321String)
},
enabled = zip321String.isNotBlank()
) {
Text(stringResource(id = R.string.send_proposal_from_uri_button))
}

Spacer(modifier = Modifier.height(16.dp))

HorizontalDivider()

Spacer(modifier = Modifier.height(16.dp))

Text(stringResource(id = R.string.send_status, sendState.toString()))
}
}
2 changes: 2 additions & 0 deletions demo-app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,11 @@
<string name="send_available_balance">Available Sapling balance: </string>
<string name="send_to_address">Destination address</string>
<string name="send_memo">Memo</string>
<string name="send_zip_321_uri">ZIP 321 Uri</string>
<string name="send_button">Send</string>

<string name="send_proposal_button">Get Proposal</string>
<string name="send_proposal_from_uri_button">Get Proposal from Uri</string>
<string name="send_proposal_status">Proposal:\n<xliff:g id="proposal" example="Fee:0.001...">%1$s</xliff:g></string>

<string name="server_textfield_value"><xliff:g id="host" example="example.com">%1$s</xliff:g>:<xliff:g id="port" example="508">%2$d</xliff:g></string>
Expand Down
Loading

0 comments on commit a13af0a

Please sign in to comment.