-
Notifications
You must be signed in to change notification settings - Fork 30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix failed back transfer #83
fix failed back transfer #83
Conversation
3c2437b
to
ff96919
Compare
ff96919
to
fb52da8
Compare
- happy path on back transfer - edge case: transfer via unknown channel
…l sub messages have succesfully completed
@@ -94,57 +121,117 @@ pub(crate) fn receive_ibc_packet( | |||
// We previously sent this NFT out on this | |||
// channel. Unlock the local version for the | |||
// receiver. | |||
OUTGOING_CLASS_TOKEN_TO_CHANNEL.remove(deps.storage, key); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This here is the bug: state change is done here in ics721
let local_prefix = get_endpoint_prefix(&packet.dest); | ||
let local_class_id = ClassId::new(format!("{}{}", local_prefix, data.class_id)); | ||
|
||
INCOMING_CLASS_TOKEN_TO_CHANNEL.save( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the other bug: state change is done also in ics721.
let submessage = action_aggregator.into_submessage( | ||
env.contract.address, | ||
receiver, | ||
callback_msg, | ||
incoming_proxy_msg, | ||
)?; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here is all the main logic: submessage itself contains a list of messags for:
- action aggregrator: creates a callback msg with a list of more messages for create or redeemining NFTs
- optional incoming proxy handling
- optional callback handling
let action_aggregator = data | ||
// sub message holds 2 to 4 messages: | ||
// - one message for voucher creation or redemption, another message for updating incoming or outgoing channel | ||
let (is_redemption, voucher_and_channel_messages) = create_voucher_and_channel_messages( | ||
deps.as_ref(), | ||
env.clone(), | ||
data.clone(), | ||
maybe_local_class_id, | ||
local_class_id.clone(), | ||
packet.clone(), | ||
)?; | ||
// - one optional incoming proxy message | ||
let incoming_proxy_msg = | ||
get_incoming_proxy_msg(deps.as_ref().storage, packet.clone(), data.clone())?; | ||
// - one optional callback message | ||
let callback_msg = create_callback_msg( | ||
deps.as_ref(), | ||
&env, | ||
&data, | ||
is_redemption, | ||
callback, | ||
local_class_id, | ||
)?; | ||
|
||
let submessage = into_submessage( | ||
env.contract.address, | ||
voucher_and_channel_messages.0, | ||
voucher_and_channel_messages.1, | ||
callback_msg, | ||
incoming_proxy_msg, | ||
)?; | ||
|
||
let response = if let Some(memo) = data.memo { | ||
IbcReceiveResponse::default().add_attribute("ics721_memo", memo) | ||
} else { | ||
IbcReceiveResponse::default() | ||
}; | ||
|
||
Ok(response | ||
.add_submessage(submessage) | ||
.add_attribute("method", "receive_ibc_packet") | ||
.add_attribute("class_id", data.class_id) | ||
.add_attribute("local_channel", packet.dest.channel_id) | ||
.add_attribute("counterparty_channel", packet.src.channel_id)) | ||
} | ||
|
||
fn create_voucher_and_channel_messages( | ||
deps: Deps, | ||
env: Env, | ||
data: NonFungibleTokenPacketData, | ||
maybe_local_class_id: Option<&str>, | ||
local_class_id: ClassId, | ||
packet: IbcPacket, | ||
) -> Result<(bool, (WasmMsg, WasmMsg)), ContractError> { | ||
let token_count = data.token_ids.len(); | ||
let redemption_or_create = data | ||
.token_ids | ||
.into_iter() | ||
.zip_optional(data.token_uris) | ||
.zip_optional(data.token_data) | ||
.try_fold( | ||
Vec::<Action>::with_capacity(token_count), | ||
|mut messages, ((token_id, token_uri), token_data)| -> StdResult<_> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
old logic is super complicated for create a callaback submessage, using Action and Action Aggregator...
let (is_redemption, voucher_and_channel_messages) = create_voucher_and_channel_messages( | ||
deps.as_ref(), | ||
env.clone(), | ||
data.clone(), | ||
maybe_local_class_id, | ||
local_class_id.clone(), | ||
packet.clone(), | ||
)?; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
...new logic leads to same result, by creating required messages - without using temporary action and aggregator objects
let add_incoming_channels: Vec<((ClassId, TokenId), String)> = creation | ||
.tokens | ||
.clone() | ||
.into_iter() | ||
.map(|token| { | ||
( | ||
(local_class_id.clone(), token.id), | ||
packet.dest.channel_id.clone(), | ||
) | ||
}) | ||
.collect(); | ||
let add_incoming_channels_msg = WasmMsg::Execute { | ||
contract_addr: env.contract.address.to_string(), | ||
msg: to_json_binary(&ExecuteMsg::Callback( | ||
CallbackMsg::AddIncomingChannelEntries(add_incoming_channels), | ||
))?, | ||
funds: vec![], | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
... CallbackMsg::AddIncomingChannelEntries which then will be added to sub message.
assertAckErrors(info.acksFromA); | ||
// assert no change before and after relay | ||
const afterWasmOutgoingClassTokenToChannelList = await outgoingChannels( | ||
wasmClient, | ||
wasmIcs721 | ||
); | ||
const afterWasmIncomingClassTokenToChannelList = await incomingChannels( | ||
wasmClient, | ||
wasmIcs721 | ||
); | ||
const afterWasmNftContractsToClassIdList = await nftContracts( | ||
wasmClient, | ||
wasmIcs721 | ||
); | ||
t.deepEqual( | ||
beforeWasmOutgoingClassTokenToChannelList, | ||
afterWasmOutgoingClassTokenToChannelList, | ||
`outgoing channels must be unchanged: | ||
- wasm before: ${JSON.stringify(beforeWasmOutgoingClassTokenToChannelList)} | ||
- wasm after: ${JSON.stringify(afterWasmOutgoingClassTokenToChannelList)}` | ||
); | ||
t.deepEqual( | ||
beforeWasmIncomingClassTokenToChannelList, | ||
afterWasmIncomingClassTokenToChannelList, | ||
`incoming channels must be unchanged: | ||
- wasm before: ${JSON.stringify(beforeWasmIncomingClassTokenToChannelList)} | ||
- wasm after: ${JSON.stringify(afterWasmIncomingClassTokenToChannelList)}` | ||
); | ||
t.deepEqual( | ||
beforeWasmNftContractsToClassIdList, | ||
afterWasmNftContractsToClassIdList, | ||
`nft contracts must be unchanged: | ||
- wasm before: ${JSON.stringify(beforeWasmNftContractsToClassIdList)} | ||
- wasm after: ${JSON.stringify(afterWasmNftContractsToClassIdList)}` | ||
); | ||
const afterOsmoOutgoingClassTokenToChannelList = await outgoingChannels( | ||
osmoClient, | ||
osmoIcs721 | ||
); | ||
const afterOsmoIncomingClassTokenToChannelList = await incomingChannels( | ||
osmoClient, | ||
osmoIcs721 | ||
); | ||
const afterOsmoNftContractsToClassIdList = await nftContracts( | ||
osmoClient, | ||
osmoIcs721 | ||
); | ||
t.deepEqual( | ||
beforeOsmoOutgoingClassTokenToChannelList, | ||
afterOsmoOutgoingClassTokenToChannelList, | ||
`outgoing channels must be unchanged: | ||
- osmo before: ${JSON.stringify(beforeOsmoOutgoingClassTokenToChannelList)} | ||
- osmo after: ${JSON.stringify(afterOsmoOutgoingClassTokenToChannelList)}` | ||
); | ||
t.deepEqual( | ||
beforeOsmoIncomingClassTokenToChannelList, | ||
afterOsmoIncomingClassTokenToChannelList, | ||
`incoming channels must be unchanged: | ||
- osmo before: ${JSON.stringify(beforeOsmoIncomingClassTokenToChannelList)} | ||
- osmo after: ${JSON.stringify(afterOsmoIncomingClassTokenToChannelList)}` | ||
); | ||
t.deepEqual( | ||
beforeOsmoNftContractsToClassIdList, | ||
afterOsmoNftContractsToClassIdList, | ||
`nft contracts must be unchanged: | ||
- osmo before: ${JSON.stringify(beforeOsmoNftContractsToClassIdList)} | ||
- osmo after: ${JSON.stringify(afterOsmoNftContractsToClassIdList)}` | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the ts relayer test, checking states before and after a failed back transfer
tokenOwner = await ownerOf(wasmClient, wasmCw721, tokenId); | ||
t.is(wasmAddr, tokenOwner.owner); | ||
|
||
// ==== test transfer NFT to osmo chain via channel WLed ONLY on osmo incoming proxy and back to wasm chain ==== |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test reproduces back transfer on failure and success:
tokenOwner = await ownerOf(osmoClient, osmoCw721, tokenId); | ||
t.is(osmoAddr, tokenOwner.owner); | ||
|
||
// test back transfer NFT to wasm chain, where onlyOsmoIncomingChannel is not WLed on wasm chain |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
test failed back transfer
|
||
info = await otherChannel.link.relayAll(); | ||
// ==== WL channel on wasm chain and test back transfer again ==== |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
migrate and WL channel for testing succesful back transfer, now NFT is unescrowd on existing collection - and NOT minted on a new collection
AdminCleanAndBurnNft { | ||
owner: String, | ||
token_id: String, | ||
class_id: String, | ||
collection: String, | ||
}, | ||
|
||
/// Admin msg in case something goes wrong. | ||
/// As a minimum it clean up state (outgoing channel), and transfer NFT if exists. | ||
/// - transfer NFT if exists | ||
AdminCleanAndUnescrowNft { | ||
recipient: String, | ||
token_id: String, | ||
class_id: String, | ||
collection: String, | ||
}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added 2 new messages for fixing "forked" NFTs due to back transfer bug.
Due to the bug, we have this invalid state on back transfer:
chain A:
- OG NFT escrowed
- new NFT on new collection (due to bug)
chain B: - NFT got burned due to bug
So on chain A, we need to:
- burn newly minted NFT
- unescrow OG NFT, transfer to recipient
These 2 msgs can only be executed by admin of ics721, once ics721 is immutable it cant be executed anymore! So in case of invalid we may fix specific NFTs.
} | ||
|
||
#[allow(clippy::too_many_arguments)] | ||
fn execute_admin_clean_and_burn_nft( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
burn "child" nft: ...
INCOMING_CLASS_TOKEN_TO_CHANNEL | ||
.remove(deps.storage, (child_class_id.clone(), token_id.clone())); | ||
TOKEN_METADATA.remove(deps.storage, (child_class_id.clone(), token_id.clone())); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- ... cleanup state
let burn_msg = WasmMsg::Execute { | ||
contract_addr: child_collection.to_string(), | ||
msg: to_json_binary(&cw721::Cw721ExecuteMsg::Burn { | ||
token_id: token_id.clone().into(), | ||
})?, | ||
funds: vec![], | ||
}; | ||
response = response.add_message(burn_msg); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- ... burn nft
} | ||
|
||
#[allow(clippy::too_many_arguments)] | ||
fn execute_admin_clean_and_unescrow_nft( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unescrow "parent" NFT
); | ||
// assert NFT on chain A is owned by wasmAddr | ||
tokenOwner = await ownerOf(wasmClient, wasmCw721, tokenId); | ||
t.is(wasmAddr, tokenOwner.owner); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NFT transferred to owner
|
||
// ==== test burn NFT on osmo chain ==== | ||
// we need to approve the contract to burn the NFT | ||
t.log(`approve NFT on osmo chain`); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we need to approve nft, so ics721 can burn it
await adminCleanAndBurnNft( | ||
osmoClient, | ||
osmoIcs721, | ||
osmoAddr, | ||
tokenId, | ||
osmoClassId, | ||
osmoCw721 | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Burn "child" NFT
); | ||
t.log(`- response: ${JSON.stringify(response, bigIntReplacer, 2)}`); | ||
allNFTs = await allTokens(osmoClient, osmoCw721); | ||
t.is(allNFTs.tokens.length, 0); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No NFTs in collection
osmoClient, | ||
osmoIcs721 | ||
); | ||
t.deepEqual( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
empty state
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
MrT and I went over these changes on a call
packet.clone(), | ||
)?; | ||
// - one optional incoming proxy message | ||
let incoming_proxy_msg = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thanks for refactoring this out to functions 👍
Bug description
Task to reproduce:
1. NFT (forward) transferred from chain A to B
Outcoume:
OUTGOING_CLASS_TOKEN_TO_CHANNEL
("returning to source" identifier for back transfer)INCOMING_CLASS_TOKEN_TO_CHANNEL
2. Transfer NFT back from B to A
Outcome:
Reason here states are updated in contract, but all other actions are handled in sub messages. So if sub messages fails, states in ics721 are not reverted. Reason here is, that on ibc receive TX must not fail for relaying Ack success or error.
What happened next: I WLed incoming proxy and did another back transfer, this time successfully, but with an unexpected result:
Reason here is because of previous failure, removal of entry in outgoing channel got not reverted. So on next transfer ics721 on chain A:
This PR fixes this, by moving state changes for incoming and outgoing channel, away from ics721 contract to a dedicated sub message. So if other sub messages like callback or incoming proxy fails, this submessage is not executed.